Integrar el MaTouch ESP32-S3 Rotary 1.28″ en HA

Integrar el MaTouch ESP32-S3 Rotary 1.28″ en HA

Hoy te enseño a usar otro interesante dispositivo con pantalla, vamos a integrar el MaTouch ESP32-S3 Rotary 1.28″ en HA.

MaTouch ESP32-S3 Rotary 1,28″

Te presento otro dispositivo fabricado por Makerfabs. Se trata de una pantalla GC9A01 táctil circular de 1,28″ (240×240 pixels), RGB. Además del control táctil, nos permite navegar con ella con el encoder que trae alrededor y un botón al pulsar la pantalla. Por sus dimensiones reducidas y su placa perforada me parece perfecto para integrarlo en otros dispositivos o muebles.

Además funciona sobre una placa ESP32-S3, por lo que podemos integrarla con ESPHome y hacer muchas más cosas. Tiene Wi-Fi y Bluetooth 5.0 por lo que, entre otras, podemos usarla como Bluetooth Proxy con Bermuda. Se alimenta con un cable USB-C, tiene lector de tarjeta Micro SD y dos conectores de expansión (I2C y GPIO), por lo que puedes añadir sensores externos. También puedes conectar una batería Lipo de 3.7V.

Requisitos previos

Para integrar el MaTouch ESP32-S3 Rotary 1.28″ en HA previamente necesitas:

MaTouch Rotary 1.28
5,0

Controlador táctil con pantalla redonda y encoder rotatorio. Ideal para domótica local vía ESPHome en Home Assistant. Conectividad WiFi y Bluetooth basada en ESP32-S3 para gestionar tu hogar con estilo.

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

Configuración en ESPHome

Sigue estos pasos para integrar el MaTouch ESP32-S3 Rotary 1.28″ en HA

  1. En Home Assistant, ve a tu complemento de ESPHome, pulsa en “New device” y “Continue”.
  2. Dale un nombre a tu dispositivo (por ejemplo, “MaTouch Rotary GC9A01”) y pulsa en “Next”.
  3. Como tipo de dispositivo selecciona “ESP32-S3”. Observarás de fondo que se ha creado un nuevo bloque para tu 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:
  name: matouch-rotary-gc9a01
  friendly_name: Matouch Rotary GC9A01
esphome:
  name: "${name}"
  friendly_name: "${friendly_name}"
  min_version: 2024.11.0
  name_add_mac_suffix: false
  platformio_options:
    board_build.flash_mode: dio
    board_build.arduino.memory_type: qio_opi
    build_flags: "-DBOARD_HAS_PSRAM"
    board_upload.maximum_ram_size: 524288
      
esp32:
  board: esp32-s3-devkitc-1
  flash_size: 16MB
  framework:
    type: esp-idf
    version: recommended
    sdkconfig_options:
      CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240: y
      CONFIG_ESP32S3_DATA_CACHE_64KB: y
      CONFIG_SPIRAM_FETCH_INSTRUCTIONS: y
      CONFIG_SPIRAM_RODATA: y
psram:
  speed: 80MHz
  mode: octal
# Enable logging
logger:
  level: DEBUG 
debug:
  update_interval: 30s
# Enable Home Assistant API
api:
  encryption:
    key: "Wvasddsa2323Ao78hdsKwLvqUTVdsffds3432"
ota:
  - platform: esphome
    password: "sdf23dsb300741930aa36a9dsf32"
mdns:
  disabled: false
  
wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  power_save_mode: none
  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Matouch-Rotary-Gc9A01"
    password: "lQsafpM678"
captive_portal:
binary_sensor:
  - platform: gpio
    name: Button
    id: front_button
    pin: 
      number: GPIO6
      inverted: True
i2c:
  - id: i2c_bus
    sda: 17
    scl: 18
    scan: true
    frequency: 400kHz
  - id: i2c_touch
    sda: 38
    scl: 39
    scan: true
    frequency: 400kHz
  
light:
  - platform: monochromatic
    name: "Backlight"
    output: backlight_output
    id: display_backlight
    restore_mode: ALWAYS_ON
output:
  - platform: ledc
    pin: GPIO7
    id: backlight_output
sensor:
  - platform: rotary_encoder
    id: encoder
    pin_a: GPIO9
    pin_b: GPIO10
spi:
  id: spi_bus
  mosi_pin: GPIO13
  clk_pin: GPIO14
time:
  - platform: homeassistant
    id: esptime
touchscreen:
  platform: cst816
  id: touch
  interrupt_pin: GPIO40
  reset_pin: GPIO16
  i2c_id: i2c_touch
  on_touch:
    - logger.log:
        format: Touch at (%d, %d)
        args: [touch.x, touch.y]
    - lambda: |-
        ESP_LOGI("cal", "x=%d, y=%d, x_raw=%d, y_raw=%0d",
            touch.x,
            touch.y,
            touch.x_raw,
            touch.y_raw
            );
display:
  - platform: ili9xxx
    id: round_display
    model: GC9A01A
    cs_pin: GPIO15
    reset_pin: GPIO11
    dc_pin: GPIO21
    invert_colors: true
    auto_clear_enabled: false
    color_order: bgr
    rotation: 90
lvgl:
  displays:
    - round_display
  touchscreens:
    - touch
  encoders:
    sensor: encoder
    enter_button: front_button
  buffer_size: 100%
  widgets:
    - label:
        align: CENTER
        id: lbl_title
        text_font: montserrat_28
        text: "Aguacatec"
  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 Matouch Rotary 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’.  Como siempre, te recomiendo que asignes una IP fija en tu router para evitar fallos en el futuro si esta cambia.
  8. Para terminar ve a Configuración > Dispositivos y servicios > ESPHome. Pulsa sobre el enlace «Configurar» correspondiente tu dispositivo. En la ventana emergente marca la casilla «Permitir que el dispositivo realice acciones de Home Assistant» y pulsa en «Enviar». Esto va a permitir que podamos controlar nuestros dispositivos desde la pantalla.

Si todo ha ido bien, deberías estar viendo lo siguiente:

Código del PokeDesk

Como es habitual, cuando cae en nuestras manos un dispositivo así nos gusta hacer algo interesante. En este caso la forma y los colores de la placa me lo estaba pidiendo a gritos. He creado un controlador de escritorio inspirado en la estética del popular juego «Pokemon», para manejar tus dispositivos. Tanto si eres fan del anime como si no, creo que la estética del dispositivo es muy atractiva!

A continuación te dejo el código completo para que lo adaptes como quieras, así como el enlace al soporte que he diseñado.

substitutions:
# Device customization
# Personalización del dispositivo
  name: "matouch-rotary-gc9a01"
  friendly_name: "Matouch Rotary GC9A01"
# Example of Devices
# Ejemplo de Dispositivos
  device1: light.tira_led_escritorio
  device1_name: "Luz"
  device1_brightness: sensor.light_brightness
  device2: climate.salon
  device2_name: "Calefacción"
  device2_temperature: sensor.climate_temperature
  device3: switch.ventilador
  device3_name: "Ventilador"
  device4: switch.impresora_3d
  device4_name: "Impresora"
  device5: vacuum.roborock_qr_598
  device5_name: "Aspirador"
# Other settings
# Otros ajustes
  allowed_characters: " ¿?¡!#%'()+,-./:°0123456789ABCDEFGHIJKLMNOPQRSTUVWYZabcdefghijklmnopqrstuvwxyzáéíóú"
################################################################################################################
esphome:
  name: "${name}"
  friendly_name: "${friendly_name}"
  min_version: 2024.11.0
  name_add_mac_suffix: false
  platformio_options:
    board_build.flash_mode: dio
    board_build.arduino.memory_type: qio_opi
    build_flags: "-DBOARD_HAS_PSRAM"
    board_upload.maximum_ram_size: 524288
      
esp32:
  board: esp32-s3-devkitc-1
  flash_size: 16MB
  framework:
    type: esp-idf
    version: recommended
    sdkconfig_options:
      CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240: y
      CONFIG_ESP32S3_DATA_CACHE_64KB: y
      CONFIG_SPIRAM_FETCH_INSTRUCTIONS: y
      CONFIG_SPIRAM_RODATA: y
psram:
  speed: 80MHz
  mode: octal
# Enable logging
logger:
  level: DEBUG 
debug:
  update_interval: 30s
# Enable Home Assistant API
api:
  encryption:
    key: "dsf4534sdfkAo78he1nKwLvsdfdf45"
ota:
  - platform: esphome
    password: "sdfsdf2300741930efwda98dsf23"
mdns:
  disabled: false
  
wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  power_save_mode: none
  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Matouch-Rotary-Gc9A01"
    password: "lQE0Hasd5612"
captive_portal:
binary_sensor:
  - platform: gpio
    name: Button
    id: front_button
    pin: 
      number: GPIO6
      inverted: True
    on_press:
      then:
      - lvgl.page.show: page_home
      - light.turn_on: display_backlight
      - if:
          condition:
            light.is_on: display_backlight
          then:
          - if:
              condition:
                lambda: |-
                  return id(device_menu).state == "${device1_name}";
              then:
              - homeassistant.action:
                  service: light.toggle
                  data:
                    entity_id: ${device1}
              - delay: 1s
              - lvgl.label.update:
                  id: lbl_device_state
                  text: !lambda |-
                      if (id(device_1).state == "off") {
                        return "Apagada";
                      } else {
                        return (std::to_string((int)id(light_brightness).state) + "%").c_str();
                      }
              - if:
                  condition:
                    lambda: |-
                      return id(device_1).state == "on";
                  then:
                    - lvgl.widget.show:
                        - id: arc_light
                  else:
                    - lvgl.widget.hide:
                        - id: arc_light
          - if:
              condition:
                lambda: |-
                  return id(device_menu).state == "${device2_name}";
              then:
              - homeassistant.action:
                  service: climate.toggle
                  data:
                    entity_id: ${device2}
              - delay: 1s
              - lvgl.label.update:
                  id: lbl_device_state
                  text: !lambda |-
                      if (id(device_2).state == "off") {
                        return "Apagada";
                      } else {
                        return (std::to_string((int)id(climate_temperature).state) + "°C").c_str();
                      }
              - if:
                  condition:
                    lambda: |-
                      return id(device_2).state == "heat";
                  then:
                    - lvgl.widget.show:
                        - id: arc_climate
                  else:
                    - lvgl.widget.hide:
                        - id: arc_climate
          - if:
              condition:
                lambda: |-
                  return id(device_menu).state == "${device3_name}";
              then:
              - homeassistant.action:
                  service: switch.toggle
                  data:
                    entity_id: ${device3}
              - delay: 1s
              - lvgl.label.update:
                  id: lbl_device_state
                  text: !lambda |-
                      return std::string(id(device_3).state == "on" ? "Encendido" : "Apagado");
          - if:
              condition:
                lambda: |-
                  return id(device_menu).state == "${device4_name}";
              then:
              - homeassistant.action:
                  service: switch.toggle
                  data:
                    entity_id: ${device4}
              - delay: 1s
              - lvgl.label.update:
                  id: lbl_device_state
                  text: !lambda |-
                      return std::string(id(device_4).state == "on" ? "Encendida" : "Apagada");
          - if:
              condition:
                lambda: |-
                  return id(device_menu).state == "${device5_name}";
              then:
              - if:
                  condition:
                    lambda: 'return id(device_5).state == "cleaning";'
                  then:
                  - homeassistant.action:
                      service: vacuum.pause
                      data:
                        entity_id: ${device5}
                  else:
                  - homeassistant.action:
                      service: vacuum.start
                      data:
                        entity_id: ${device5}
              - delay: 1s
              - lvgl.label.update:
                  id: lbl_device_state
                  text: !lambda |-
                      return std::string(id(device_5).state == "charging" ? "En la base" : "Limpiando");
font:
  - file:
      type: gfonts
      family: Roboto
      weight: bold
    id: font_menu
    size: 18
    glyphs: ${allowed_characters}
  - file:
      type: gfonts
      family: Roboto
      weight: bold
    id: font_state
    size: 14
    glyphs: ${allowed_characters}
i2c:
  - id: i2c_bus
    sda: 17
    scl: 18
    scan: true
    frequency: 400kHz
  - id: i2c_touch
    sda: 38
    scl: 39
    scan: true
    frequency: 400kHz
image:
  - file: https://aguacatec.es/wp-content/uploads/2025/05/pokeball_off.jpg
    id: pokeball_off
    resize: 240x240
    type: RGB565
    transparency: alpha_channel
  - file: https://aguacatec.es/wp-content/uploads/2025/05/pokeball_on.jpg
    id: pokeball_on
    resize: 240x240
    type: RGB565
    transparency: alpha_channel
  - file: https://aguacatec.es/wp-content/uploads/2025/05/pokemon_luz.jpg
    id: pokemon_luz
    resize: 240x240
    type: RGB565
    transparency: alpha_channel
  - file: https://aguacatec.es/wp-content/uploads/2025/05/pokemon_calefaccion.jpg
    id: pokemon_calefaccion
    resize: 240x240
    type: RGB565
    transparency: alpha_channel
  - file: https://aguacatec.es/wp-content/uploads/2025/05/pokemon_ventilador.jpg
    id: pokemon_ventilador
    resize: 240x240
    type: RGB565
    transparency: alpha_channel
  - file: https://aguacatec.es/wp-content/uploads/2025/05/pokemon_impresora.jpg
    id: pokemon_impresora
    resize: 240x240
    type: RGB565
    transparency: alpha_channel
  - file: https://aguacatec.es/wp-content/uploads/2025/05/pokemon_aspirador.jpg
    id: pokemon_aspirador
    resize: 240x240
    type: RGB565
    transparency: alpha_channel
light:
  - platform: monochromatic
    name: "Backlight"
    output: backlight_output
    id: display_backlight
    restore_mode: ALWAYS_ON
    on_turn_off:
      lvgl.animimg.stop: animation_pokeball
    on_turn_on:
      select.first:
        id: device_menu
output:
  - platform: ledc
    pin: GPIO7
    id: backlight_output
select:
  - platform: template
    name: Dispositivos
    id: device_menu
    optimistic: true
    options:
    - ${device1_name}
    - ${device2_name}
    - ${device3_name}
    - ${device4_name}
    - ${device5_name}
    on_value:
      then:
        - lvgl.label.update:
            id: lbl_device
            text: !lambda 'return x;'
        - lvgl.image.update:
            id: pokemon
            src: !lambda |-
              if (x == "${device1_name}") {
                return id(pokemon_luz);  
              } else if (x == "${device2_name}") {
                return id(pokemon_calefaccion);
              } else if (x == "${device3_name}") {
                return id(pokemon_ventilador);
              } else if (x == "${device4_name}") {
                return id(pokemon_impresora);
              } else if (x == "${device5_name}") {
                return id(pokemon_aspirador);
              } else {
                return id(pokeball_off);  // Valor predeterminado
              }
        - lvgl.label.update:
            id: lbl_device_state
            text: !lambda |-
              if (x == "${device1_name}") {
                if ((id(device_1).state == "off") || isnan(id(light_brightness).state)) {
                  return "Apagada";
                } else {
                  return (std::to_string((int)id(light_brightness).state) + "%").c_str();
                }
              } else if (x == "${device2_name}") {
                if (id(device_2).state == "off") {
                  return "Apagada";
                } else {
                  return (std::to_string((int)id(climate_temperature).state) + "°C").c_str();
                }
              } else if (x == "${device3_name}") {
                return id(device_3).state == "on" ? "Encendido" : "Apagado";
              } else if (x == "${device4_name}") {
                return id(device_4).state == "on" ? "Encendida" : "Apagada";
              } else if (x == "${device5_name}") {
                return id(device_5).state == "docked" ? "En la base" : "Limpiando";
              } else {
                return "Bienvenido!";
              }
        - if:
            condition:
              lambda: |-
                return id(device_menu).state == "${device1_name}" && id(device_1).state == "on";
            then:
              - lvgl.widget.show:
                  - id: arc_light
            else:
              - lvgl.widget.hide:
                  - id: arc_light
        - if:
            condition:
              lambda: |-
                return id(device_menu).state == "${device2_name}" && id(device_2).state == "heat";
            then:
              - lvgl.widget.show:
                  - id: arc_climate
            else:
              - lvgl.widget.hide:
                  - id: arc_climate
sensor:
  - platform: rotary_encoder
    id: encoder
    pin_a: GPIO9
    pin_b: GPIO10
    on_clockwise:
      then:
        - lvgl.page.show: page_home
        - light.turn_on: display_backlight
        - select.next:
            id: device_menu
            cycle: true
    on_anticlockwise:
      then:
        - lvgl.page.show: page_home
        - light.turn_on: display_backlight
        - select.previous:
            id: device_menu
            cycle: true
  - platform: homeassistant
    id: light_brightness
    entity_id: ${device1_brightness}
    on_value:
      then:
      - lvgl.arc.update:
          id: arc_light
          value: !lambda 'return (int)x;'
      - if:
          condition:
            lambda: |-
              return id(device_menu).state == "${device1_name}" && id(device_1).state == "on";
          then:
          - lvgl.label.update:
              id: lbl_device_state
              text:
                format: "%.0f%%"
                args:
                  - id(light_brightness).state
  - platform: homeassistant
    id: climate_temperature
    entity_id: ${device2_temperature}
    on_value:
      then:
      - lvgl.arc.update:
          id: arc_climate
          value: !lambda 'return (int)x;'
      - if:
          condition:
            lambda: |-
              return id(device_menu).state == "${device2_name}" && id(device_2).state == "heat";
          then:
          - lvgl.label.update:
              id: lbl_device_state
              text:
                format: "%.0f °C"
                args:
                  - id(climate_temperature).state
spi:
  id: spi_bus
  mosi_pin: GPIO13
  clk_pin: GPIO14
text_sensor:
  - platform: homeassistant
    id: device_1
    entity_id: ${device1}
    internal: true
  - platform: homeassistant
    id: device_2
    entity_id: ${device2}
    internal: true
  - platform: homeassistant
    id: device_3
    entity_id: ${device3}
    internal: true
  - platform: homeassistant
    id: device_4
    entity_id: ${device4}
    internal: true
  - platform: homeassistant
    id: device_5
    entity_id: ${device5}
    internal: true
time:
  - platform: homeassistant
    id: esptime
touchscreen:
  platform: cst816
  id: touch
  interrupt_pin: GPIO40
  reset_pin: GPIO16
  i2c_id: i2c_touch
  transform:
    mirror_x: false
    mirror_y: true
    swap_xy: true
  on_touch:
    - logger.log:
        format: Touch at (%d, %d)
        args: [touch.x, touch.y]
    - lambda: |-
        ESP_LOGI("cal", "x=%d, y=%d, x_raw=%d, y_raw=%0d",
            touch.x,
            touch.y,
            touch.x_raw,
            touch.y_raw
            );
  on_release:
    then:
      - if:
          condition: lvgl.is_paused
          then:
            - lvgl.page.show: page_home
            - light.turn_on: display_backlight
            - lvgl.resume:
            - lvgl.widget.redraw:
display:
  - platform: ili9xxx
    id: round_display
    model: GC9A01A
    cs_pin: GPIO15
    reset_pin: GPIO11
    dc_pin: GPIO21
    invert_colors: true
    auto_clear_enabled: false
    color_order: bgr
    rotation: 90
lvgl:
  displays:
    - round_display
  touchscreens:
    - touch
  encoders:
    sensor: encoder
    enter_button: front_button
  buffer_size: 25%
  style_definitions:
    - id: style_container
      align: CENTER
      bg_color: 0xffffff
      bg_opa: 60%
      y: 100
      width: 240
      height: 60
      border_width: 0
      pad_top: -15
    - id: style_roller
      text_font: font_menu
      text_color: 0x000000
      align: CENTER
      pad_top: 0
    - id: style_device_state
      text_font: font_state
      text_color: 0x888888
      align: CENTER
      pad_top: 32
    - id: style_arc
      align: CENTER
      width: 225
      height: 225
      arc_color: 0xe0e0e0
  on_idle:
    - timeout: 5s
      then:
        - lvgl.animimg.start: animation_pokeball
        - logger.log: idle 5s timeout
        - lvgl.page.show: page_iddle
        - delay: 5s
        - lvgl.pause:
        - light.turn_off:
            id: display_backlight
            transition_length: 5s
  pages:
    - id: page_iddle
      widgets:
        - animimg:
            align: CENTER
            id: animation_pokeball
            src: [ pokeball_off, pokeball_on ]
            duration: 1000ms
    - id: page_home
      scrollbar_mode: "OFF"
      widgets:
        - image:
            align: CENTER
            src: pokemon_luz
            id: pokemon
        - obj:
            styles: style_container
            widgets:
            - label:
                styles: style_roller
                id: lbl_device
                text: "Aguacatec"
            - label:
                styles: style_device_state
                id: lbl_device_state
                text: "Bienvenido!"
        - arc:
            styles: style_arc
            id: arc_light
            indicator:
              arc_color: 0xffba00
            knob: 
              bg_color: 0xffba00
            adjustable: true
            value: 50
            min_value: 0
            max_value: 100
            hidden: true
            start_angle: 150
            end_angle: 30
            on_release:
              - homeassistant.action:
                  action: light.turn_on
                  data:
                    entity_id: ${device1}
                    brightness_pct: !lambda return int(x);
        - arc:
            styles: style_arc
            id: arc_climate
            indicator:
              arc_color: 0xff6600
            knob: 
              bg_color: 0xff6600
            adjustable: true
            value: 20
            min_value: 5
            max_value: 25
            hidden: true
            start_angle: 150
            end_angle: 30
            on_release:
              - homeassistant.action:
                  action: climate.set_temperature
                  data:
                    entity_id: ${device2}
                    temperature: !lambda return int(x);