[Blueprint/Showcase] M5Stack Faces Smart Remote Control via ESPHome (Fixed ILI9342 & Memory Crashes)
Hi everyone,
I would like to share a fully optimized and tested ESPHome configuration for the M5Stack Core v1 (Basic/Gray) combined with the Faces Encoder Panel (Rotary encoder + 12 RGB LED ring).
Using newer ESPHome versions (2025/2026) with this specific old-school hardware usually triggers major headaches: blank screens, vertical color lines, or immediate boots loops due to memory allocation failures (Failed to init Memory: YES!), because the original Core device lacks PSRAM.
After systematic troubleshooting, we successfully mapped out the undocumented pinout and register settings for the built-in ILI9342 controller under the Arduino framework. Everything now runs fast, stable, and completely in crisp landscape mode!
Key Solutions Included in This Template:
- DRAM Crash Fix: Implemented the modern
mipi_spidriver with a restrictedbuffer_size: 16%. This splits the graphics buffer and keeps the device from running out of RAM. - Undocumented Pinout: Fixed the hidden wiring used in these Core revisions: CS: 14, DC: 27, and Reset: 33.
- ILI9342 Signal Calibration: Locked the SPI bus to
spi_mode: 3at20MHz. Removed vertical yellow/magenta artifacts by enforcingcolor_order: BGRandinvert_colors: truewithrotation: 270. - C++ Puffer Safety: Corrected text buffer declarations (
char raum_buffer[32]) inside lambdas to prevent silent memory fragmentation during runtimesnprintfactions. - Nativ I2C Encoder Logic: Direct register scanning of the Faces keyboard base (
0x5E) for ultra-responsive wheel turns, button presses, and synchronized LED-ring feedbacks.
Part 1: Core Framework, Display Sync & Fonts
Paste this into your ESPHome configuration file:
yaml
esphome:
name: m5stack-faces-remote
on_boot:
priority: 100
then:
- light.turn_on:
id: back_light
brightness: 100%
esp32:
board: m5stack-core-esp32
framework:
type: arduino # Required for solid data_rate divider mappings
logger:
wifi:
ssid: "YOUR_SSID"
password: "YOUR_PASSWORD"
api:
id: ha_api
ota:
- platform: esphome
i2c:
sda: GPIO21
scl: GPIO22
scan: true
i2c_device:
id: faces_hardware
address: 0x5E
spi:
id: spi_bus
clk_pin: 18
mosi_pin: 23
output:
- platform: ledc
pin: 32
id: gpio_32_backlight_pwm
- platform: ledc
pin: GPIO25
id: buzzer_output
frequency: 4000Hz
light:
- platform: monochromatic
output: gpio_32_backlight_pwm
name: "Display Backlight"
id: back_light
restore_mode: ALWAYS_ON
default_transition_length: 0s
display:
- platform: mipi_spi
id: tft_display
spi_id: spi_bus
model: ILI9341
cs_pin: 14
dc_pin: 27
reset_pin: 33
data_rate: 20000000Hz
spi_mode: 3
dimensions:
width: 320
height: 240
offset_height: 0
offset_width: 0
buffer_size: 16%
color_depth: 16bit
color_order: BGR
invert_colors: true
rotation: 270
transform:
mirror_x: false
mirror_y: false
swap_xy: false
lambda: |-
it.fill(Color::BLACK);
it.print(160, 10, id(font_normal), Color::WHITE, TextAlign::TOP_CENTER, "M5Stack Faces Remote");
it.line(10, 35, 310, 35, Color(180, 180, 180));
if (id(aktueller_modus) == 0) {
it.print(20, 50, id(font_normal), Color(0, 255, 255), TextAlign::TOP_LEFT, "Modus: RAUM AUSWAHL");
} else if (id(aktueller_modus) == 1) {
it.print(20, 50, id(font_normal), Color(0, 255, 0), TextAlign::TOP_LEFT, "Modus: GERAET AUSWAHL");
} else if (id(aktueller_modus) == 2) {
it.print(20, 50, id(font_normal), Color(255, 165, 0), TextAlign::TOP_LEFT, "Modus: DIMMEN");
}
const char* raum_name = "Unbekannt";
const char* raum_icon = "\U000F02DC";
switch(id(gewaehlter_raum)) {
case 0: raum_name = "Kueche"; raum_icon = "\U000F0190"; break;
case 1: raum_name = "Schlafzimmer"; raum_icon = "\U000F009F"; break;
case 2: raum_name = "Esszimmer"; raum_icon = "\U000F05A0"; break;
case 3: raum_name = "Wohnzimmer"; raum_icon = "\U000F0538"; break;
case 4: raum_name = "Pool"; raum_icon = "\U000F0604"; break;
case 5: raum_name = "Terrasse West"; raum_icon = "\U000F03AF"; break;
case 6: raum_name = "Terrasse Sued"; raum_icon = "\U000F03AF"; break;
}
it.print(20, 90, id(font_icons), Color::WHITE, TextAlign::TOP_LEFT, raum_icon);
char raum_buffer[32];
snprintf(raum_buffer, sizeof(raum_buffer), " %s", raum_name);
it.print(65, 95, id(font_normal), Color::WHITE, TextAlign::TOP_LEFT, raum_buffer);
bool ist_dimmer = (id(gewaehlter_raum) == 0 || id(gewaehlter_raum) == 1 || id(gewaehlter_raum) == 3);
if (ist_dimmer && id(gewaehltes_geraet) == 0) {
it.print(20, 140, id(font_icons), Color(255, 255, 0), TextAlign::TOP_LEFT, "\U000F1417");
it.print(65, 145, id(font_normal), Color::WHITE, TextAlign::TOP_LEFT, "Typ: Dimmer (Licht)");
} else if (id(gewaehlter_raum) == 4) {
it.print(20, 140, id(font_icons), Color(0, 255, 255), TextAlign::TOP_LEFT, "\U000F0D84");
it.print(65, 145, id(font_normal), Color::WHITE, TextAlign::TOP_LEFT, "Typ: Poolpumpe");
} else {
it.print(20, 140, id(font_icons), Color(144, 238, 144), TextAlign::TOP_LEFT, "\U000F0312");
it.print(65, 145, id(font_normal), Color::WHITE, TextAlign::TOP_LEFT, "Typ: Schalter (Licht)");
}
if (id(aktueller_modus) == 2) {
char wert_buffer[16];
snprintf(wert_buffer, sizeof(wert_buffer), "%d %%", id(einstell_wert));
it.print(160, 200, id(font_large), Color(255, 165, 0), TextAlign::TOP_CENTER, wert_buffer);
}
font:
- file: "gandhi_sans.ttf"
id: font_normal
size: 18
glyphs: " !_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,:-%ÄÖÜäöüß()"
- file: "gandhi_sans.ttf"
id: font_large
size: 32
glyphs: " !_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,:-%ÄÖÜäöüß()"
- file: "materialdesignicons-webfont.ttf"
id: font_icons
size: 32
glyphs:
- "\U000F02DC"
- "\U000F0190"
- "\U000F009F"
- "\U000F05A0"
- "\U000F0538"
- "\U000F0604"
- "\U000F03AF"
- "\U000F1417"
- "\U000F0312"
- "\U000F0D84"
Verwende Code mit Vorsicht.
Part 2: Automations, Home Assistant Links & Encoder Registers
Append this directly to the end of the script:
yaml
globals:
- id: aktueller_modus
type: int
initial_value: '0'
- id: gewaehlter_raum
type: int
initial_value: '0'
- id: gewaehltes_geraet
type: int
initial_value: '0'
- id: einstell_wert
type: int
initial_value: '50'
- id: rgb_r
type: int
initial_value: '255'
- id: rgb_g
type: int
initial_value: '255'
- id: rgb_b
type: int
initial_value: '255'
sensor:
- platform: homeassistant
id: ha_brightness_wohnzimmer
entity_id: light.wohnzimmer_hauptlicht
attribute: brightness
- platform: homeassistant
id: ha_brightness_kueche
entity_id: light.kueche_led_stripe
attribute: brightness
- platform: homeassistant
id: ha_brightness_schlafzimmer
entity_id: light.schlafzimmer_licht
attribute: brightness
text_sensor:
- platform: homeassistant
id: led_stripe_farbe
entity_id: light.kueche_led_stripe
attribute: rgb_color
- platform: homeassistant
id: ha_status_esszimmer
entity_id: light.esszimmer_lampe
- platform: homeassistant
id: ha_status_pool
entity_id: switch.pool_pumpe
- platform: homeassistant
id: ha_status_terrasse_west
entity_id: light.terrasse_west_wand
- platform: homeassistant
id: ha_status_terrasse_sued
entity_id: light.terrasse_sued_spot
binary_sensor:
- platform: gpio
pin:
number: GPIO38
inverted: true
name: "M5Stack Button B (Back)"
on_press:
then:
- lambda: |-
if (id(aktueller_modus) > 0) id(aktueller_modus)--;
rtttl:
output: buzzer_output
id: rtttl_player
script:
- id: toggle_esszimmer
then:
- homeassistant.service:
service: light.toggle
data:
entity_id: light.esszimmer_lampe
- id: toggle_pool
then:
- homeassistant.service:
service: switch.toggle
data:
entity_id: switch.pool_pumpe
- id: toggle_terrasse_west
then:
- homeassistant.service:
service: light.toggle
data:
entity_id: light.terrasse_west_wand
- id: toggle_terrasse_sued
then:
- homeassistant.service:
service: light.toggle
data:
entity_id: light.toggle_terrasse_sued
- id: dimmer_kueche
then:
- homeassistant.service:
service: light.turn_on
data:
entity_id: light.kueche_led_stripe
brightness_pct: !lambda "return id(einstell_wert);"
- id: dimmer_schlafzimmer
then:
- homeassistant.service:
service: light.turn_on
data:
entity_id: light.schlafzimmer_licht
brightness_pct: !lambda "return id(einstell_wert);"
- id: dimmer_wohnzimmer
then:
- homeassistant.service:
service: light.turn_on
data:
entity_id: light.wohnzimmer_hauptlicht
brightness_pct: !lambda "return id(einstell_wert);"
interval:
- interval: 50ms
then:
- lambda: |-
auto setLED = [](uint8_t index, uint8_t r, uint8_t g, uint8_t b) {
uint8_t data[] = {index, r, g, b};
id(faces_hardware).write(data, 4);
};
auto playTone = [](bool is_click) {
if (is_click) id(rtttl_player).play("click:d=4,o=6,b=160:32c");
else id(rtttl_player).play("confirm:d=4,o=6,b=180:16e");
};
uint8_t schritte_reg = 0x00;
uint8_t schritte_wert = 0;
id(faces_hardware).read_register(schritte_reg, &schritte_wert, 1);
int8_t encoder_schritte = (int8_t)schritte_wert;
if (encoder_schritte != 0) {
playTone(true);
if (id(aktueller_modus) == 0) {
id(gewaehlter_raum) += encoder_schritte;
if (id(gewaehlter_raum) > 6) id(gewaehlter_raum) = 6;
if (id(gewaehlter_raum) < 0) id(gewaehlter_raum) = 0;
} else if (id(aktueller_modus) == 1) {
id(gewaehltes_geraet) += encoder_schritte;
if (id(gewaehltes_geraet) > 1) id(gewaehltes_geraet) = 1;
if (id(gewaehltes_geraet) < 0) id(gewaehltes_geraet) = 0;
} else if (id(aktueller_modus) == 2) {
id(einstell_wert) += (encoder_schritte * 5);
if (id(einstell_wert) > 100) id(einstell_wert) = 100;
if (id(einstell_wert) < 0) id(einstell_wert) = 0;
}
}
uint8_t button_reg = 0x10;
uint8_t button_state = 1;
id(faces_hardware).read_register(button_reg, &button_state, 1);
static uint8_t vorheriger_status = 1;
if (button_state == 0 && vorheriger_status == 1) {
playTone(false);
if (id(aktueller_modus) == 0) {
id(gewaehltes_geraet) = 0;
id(aktueller_modus) = 1;
} else if (id(aktueller_modus) == 1) {
switch(id(gewaehlter_raum)) {
case 0:
if (id(gewaehltes_geraet) == 0) {
if (id(ha_brightness_kueche).has_state()) id(einstell_wert) = (id(ha_brightness_kueche).state * 100) / 255; else id(einstell_wert) = 0;
id(aktueller_modus) = 2;
}
break;
case 1:
if (id(gewaehltes_geraet) == 0) {
if (id(ha_brightness_schlafzimmer).has_state()) id(einstell_wert) = (id(ha_brightness_schlafzimmer).state * 100) / 255; else id(einstell_wert) = 0;
id(aktueller_modus) = 2;
}
break;
case 2:
id(toggle_esszimmer)->execute();
break;
case 3:
if (id(gewaehltes_geraet) == 0) {
if (id(ha_brightness_wohnzimmer).has_state()) id(einstell_wert) = (id(ha_brightness_wohnzimmer).state * 100) / 255; else id(einstell_wert) = 0;
id(aktueller_modus) = 2;
}
break;
case 4:
id(toggle_pool)->execute();
break;
case 5:
if (id(gewaehltes_geraet) == 0) id(toggle_terrasse_west)->execute();
break;
case 6:
if (id(gewaehltes_geraet) == 0) id(toggle_terrasse_sued)->execute();
break;
}
} else if (id(aktueller_modus) == 2) {
if (id(gewaehlter_raum) == 0 && id(gewaehltes_geraet) == 0) id(dimmer_kueche)->execute();
if (id(gewaehlter_raum) == 1 && id(gewaehltes_geraet) == 0) id(dimmer_schlafzimmer)->execute();
if (id(gewaehlter_raum) == 3 && id(gewaehltes_geraet) == 0) id(dimmer_wohnzimmer)->execute();
id(aktueller_modus) = 1;
}
}
vorheriger_status = button_state;
static int last_wert = -1, last_modus = -1, last_raum = -1;
if (id(einstell_wert) != last_wert || id(aktueller_modus) != last_modus || id(gewaehlter_raum) != last_raum) {
last_wert = id(einstell_wert); last_modus = id(aktueller_modus); last_raum = id(gewaehlter_raum);
for (int i = 0; i < 12; i++) {
if (id(aktueller_modus) == 0) {
if (i == (id(gewaehlter_raum) * 11) / 6) setLED(i, 0, 102, 204); else setLED(i, 0, 0, 0);
} else if (id(aktueller_modus) == 1) {
if (i == id(gewaehltes_geraet) * 6) setLED(i, 0, 200, 100); else setLED(i, 0, 0, 0);
} else if (id(aktueller_modus) == 2) {
int leds_to_light = (id(einstell_wert) * 12) / 100;
if (i < leds_to_light) setLED(i, 255, 150, 0); else setLED(i, 0, 0, 0);
}
}
}
Verwende Code mit Vorsicht.