Add ESPHome display component; ring-buffer driver shared with PlatformIO

Driver (components/el512/el512_panel.*, ESP-IDF only):
- DMA ring of 8 x 4 lines (17 KB) refilled from the I2S EOF interrupt
  (IRAM) instead of a 142 KB full-frame stream; ~107 KB heap freed.
- 256-entry lookup table encodes 8 pixels per step.
- Back buffer + displayed buffer, swapped at the start of a frame.
- PlatformIO builds it via lib_extra_dirs (EL512Core); lib/EL512 keeps
  the Adafruit GFX front end.

ESPHome external component (platform: el512):
- DisplayBuffer with optional ENABLE / _LOWPWR pins, set_enabled(),
  set_low_power(), helper functions for page lambdas.
- still_alive_page: plays the Portal end-credits show, then returns to
  the previous page.

Still Alive engine moved to components/el512/still_alive.* with a built-in
5x7 font (from Adafruit GFX, BSD); the PlatformIO demo uses the same code.
The importer writes components/el512/still_alive_data.h and stops at 200 s
(--until-ms), matching the hand-trimmed data.

esphome/el512-panel.yaml: 8 pages (clock, forecast, agenda, dashboard,
now playing, Plex, solar, Still Alive), page select, rotation, display
and low-power switches, message text. home_assistant/packages/el512_panel.yaml:
helper template sensors for forecast, agenda, now playing and Plex streams.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-18 20:20:13 +02:00
1 parent 410885de24
commit 963590cdfe
27 files changed
+2110 -828

No files matched your search

+557
View File
@@ -0,0 +1,557 @@
# Planar EL512.256-H (512x256 amber EL) on a Wemos D1 mini ESP32, as a
# Home Assistant information display.
#
# Needs: secrets.yaml (wifi_ssid, wifi_password, el512_api_key, el512_ota_password)
# and the helper sensors from home_assistant/packages/el512_panel.yaml.
# Wiring: see the README of https://mangavalk.com/mangavalk/planar-EL512.256-H
substitutions:
name: el512-panel
friendly_name: EL512 Panel
weather: weather.knmi_home
envoy: sensor.envoy_122225067403
esphome:
name: ${name}
friendly_name: ${friendly_name}
esp32:
board: wemos_d1_mini32
framework:
type: esp-idf
external_components:
- source:
type: git
url: https://mangavalk.com/mangavalk/planar-EL512.256-H.git
ref: main
components: [el512]
refresh: 0s
logger:
api:
encryption:
key: !secret el512_api_key
ota:
- platform: esphome
password: !secret el512_ota_password
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap:
ssid: ${name}
time:
- platform: homeassistant
id: ha_time
timezone: Europe/Amsterdam
globals:
- id: rotation_elapsed
type: int
initial_value: "0"
- id: np_updated
type: uint32_t
initial_value: "0"
# --- Fonts -------------------------------------------------------------------
font:
- file: "gfonts://Roboto"
id: f_s
size: 16
glyphsets: [GF_Latin_Core]
- file: "gfonts://Roboto"
id: f_m
size: 22
glyphsets: [GF_Latin_Core]
- file: "gfonts://Roboto"
id: f_l
size: 32
glyphsets: [GF_Latin_Core]
- file: "gfonts://Roboto@500"
id: f_big
size: 48
glyphs: " 0123456789.,:-+%°CWkhMbits/"
- file: "gfonts://Roboto@300"
id: f_clock
size: 120
glyphs: "0123456789:"
- file: &mdi
type: web
url: https://github.com/Templarian/MaterialDesign-Webfont/raw/master/fonts/materialdesignicons-webfont.ttf
id: icons_xl
size: 96
glyphs: &weather_icons
- "\U000F0594" # weather-night
- "\U000F0590" # weather-cloudy
- "\U000F0591" # weather-fog
- "\U000F0592" # weather-hail
- "\U000F0593" # weather-lightning
- "\U000F067E" # weather-lightning-rainy
- "\U000F0595" # weather-partly-cloudy
- "\U000F0596" # weather-pouring
- "\U000F0597" # weather-rainy
- "\U000F0598" # weather-snowy
- "\U000F067F" # weather-snowy-rainy
- "\U000F0599" # weather-sunny
- "\U000F059D" # weather-windy
- "\U000F059E" # weather-windy-variant
- "\U000F05D6" # alert-circle-outline
- file: *mdi
id: icons_l
size: 56
glyphs: *weather_icons
- file: *mdi
id: icons_m
size: 32
glyphs:
- "\U000F0A72" # solar-power
- "\U000F0D3E" # transmission-tower
- "\U000F06A1" # home-outline
- "\U000F06BA" # plex
- "\U000F0E17" # calendar-month
- "\U000F040A" # play
- "\U000F0387" # music-note
- "\U000F048D" # server-network
- "\U000F048E" # server-network-off
- "\U000F06B0" # update
- "\U000F054B" # umbrella-outline
- "\U000F072E" # arrow-down-bold
- "\U000F0737" # arrow-up-bold
- file: *mdi
id: icons_s
size: 18
glyphs:
- "\U000F054B" # umbrella-outline
# --- Data from Home Assistant -------------------------------------------------
text_sensor:
- platform: homeassistant
id: weather_cond
entity_id: ${weather}
- platform: homeassistant
id: forecast_days
entity_id: sensor.el512_forecast
attribute: days
- platform: homeassistant
id: agenda_events
entity_id: sensor.el512_agenda
attribute: events
- platform: homeassistant
id: np_state
entity_id: sensor.el512_now_playing
- platform: homeassistant
id: np_info
entity_id: sensor.el512_now_playing
attribute: info
on_value:
- lambda: id(np_updated) = millis();
- platform: homeassistant
id: plex_server
entity_id: sensor.mangavalkserver
- platform: homeassistant
id: plex_streams
entity_id: sensor.el512_plex_streams
attribute: streams
- platform: homeassistant
id: plex_update
entity_id: update.plex_media_server_mangavalkserver
- platform: homeassistant
id: plex_version
entity_id: update.plex_media_server_mangavalkserver
attribute: installed_version
sensor:
- platform: homeassistant
id: weather_temp
entity_id: ${weather}
attribute: temperature
- platform: homeassistant
id: weather_hum
entity_id: ${weather}
attribute: humidity
- platform: homeassistant
id: weather_wind
entity_id: ${weather}
attribute: wind_speed
# Plex / Tautulli
- platform: homeassistant
id: plex_watching
entity_id: sensor.mangavalkserver
- platform: homeassistant
id: tautulli_bandwidth
entity_id: sensor.tautulli_total_bandwidth
# Solar (Enphase Envoy) and grid
- platform: homeassistant
id: solar_power
entity_id: ${envoy}_current_power_production
- platform: homeassistant
id: solar_today
entity_id: ${envoy}_today_s_energy_production
- platform: homeassistant
id: house_power
entity_id: ${envoy}_current_power_consumption
- platform: homeassistant
id: grid_import
entity_id: sensor.grid_import_power
- platform: homeassistant
id: grid_export
entity_id: sensor.grid_export_power
# Room temperatures (thermostat zones)
- platform: homeassistant
id: t_woonkamer
entity_id: climate.woonkamer
attribute: current_temperature
- platform: homeassistant
id: t_voorkamer
entity_id: climate.voorkamer
attribute: current_temperature
- platform: homeassistant
id: t_gang
entity_id: climate.vloerverwarm
attribute: current_temperature
- platform: homeassistant
id: t_patrick
entity_id: climate.patrick
attribute: current_temperature
- platform: homeassistant
id: t_badkamer
entity_id: climate.badkamer
attribute: current_temperature
- platform: homeassistant
id: t_stans_paul
entity_id: climate.stans_paul
attribute: current_temperature
- platform: homeassistant
id: t_vloer
entity_id: climate.vloererwarmin
attribute: current_temperature
- platform: homeassistant
id: t_huis
entity_id: climate.vigilantestraat
attribute: current_temperature
graph:
- id: solar_graph
sensor: solar_power
duration: 16h
width: 496
height: 118
x_grid: 2h
y_grid: 1000
min_value: 0
# --- Controls in Home Assistant ------------------------------------------------
switch:
- platform: template
id: sw_display
name: Display
icon: mdi:monitor
restore_mode: RESTORE_DEFAULT_ON
lambda: return id(panel).is_enabled();
turn_on_action:
- lambda: id(panel).set_enabled(true);
turn_off_action:
- lambda: id(panel).set_enabled(false);
- platform: template
id: sw_low_power
name: Low power mode
icon: mdi:leaf
restore_mode: RESTORE_DEFAULT_OFF
lambda: return id(panel).is_low_power();
turn_on_action:
- lambda: id(panel).set_low_power(true);
turn_off_action:
- lambda: id(panel).set_low_power(false);
- platform: template
id: sw_pause
name: Pause page rotation
icon: mdi:pause-circle-outline
optimistic: true
restore_mode: RESTORE_DEFAULT_OFF
number:
- platform: template
id: page_interval
name: Page interval
icon: mdi:timer-outline
unit_of_measurement: s
min_value: 5
max_value: 300
step: 5
initial_value: 20
optimistic: true
restore_value: true
text:
- platform: template
id: panel_message
name: Message
icon: mdi:message-text-outline
mode: text
max_length: 100
optimistic: true
restore_value: true
initial_value: ""
on_value:
- component.update: panel
select:
- platform: template
id: page_select
name: Page
icon: mdi:book-open-page-variant-outline
options:
- Clock
- Forecast
- Agenda
- Dashboard
- Now playing
- Plex
- Solar
- Still Alive
initial_option: Clock
set_action:
- lambda: |-
id(rotation_elapsed) = 0;
if (x == "Still Alive") { id(panel).play_still_alive(); id(panel).update(); return; }
display::DisplayPage *p = id(page_clock);
if (x == "Forecast") p = id(page_forecast);
else if (x == "Agenda") p = id(page_agenda);
else if (x == "Dashboard") p = id(page_dashboard);
else if (x == "Now playing") p = id(page_media);
else if (x == "Plex") p = id(page_plex);
else if (x == "Solar") p = id(page_solar);
id(panel).show_page(p);
id(panel).update();
button:
- platform: template
name: Play Still Alive
icon: mdi:cake-variant-outline
on_press:
- lambda: |-
id(panel).play_still_alive();
id(panel).update();
# Rotate pages; skip Still Alive (start it from HA) and Now playing when idle.
interval:
- interval: 1s
then:
- lambda: |-
if (id(sw_pause).state || id(panel).is_still_alive_playing()) { id(rotation_elapsed) = 0; return; }
if (++id(rotation_elapsed) < (int) id(page_interval).state) return;
id(rotation_elapsed) = 0;
for (int i = 0; i < 8; i++) {
id(panel).show_next_page();
const auto *p = id(panel).get_active_page();
const std::string &np = id(np_state).state;
if (p == id(page_still_alive)) continue;
if (p == id(page_media) && (np == "idle" || np.empty())) continue;
break;
}
id(panel).update();
# --- The panel and its pages ----------------------------------------------------
display:
- platform: el512
id: panel
enable_pin: GPIO25
low_power_pin: GPIO26
update_interval: 1s
still_alive_page: page_still_alive
on_page_change:
- then:
- lambda: |-
const char *name = "Clock";
if (to == id(page_forecast)) name = "Forecast";
else if (to == id(page_agenda)) name = "Agenda";
else if (to == id(page_dashboard)) name = "Dashboard";
else if (to == id(page_media)) name = "Now playing";
else if (to == id(page_plex)) name = "Plex";
else if (to == id(page_solar)) name = "Solar";
else if (to == id(page_still_alive)) name = "Still Alive";
if (id(page_select).current_option() != name) id(page_select).publish_state(name);
pages:
# 1. Clock, date and current weather
- id: page_clock
lambda: |-
auto now = id(ha_time).now();
if (now.is_valid()) {
it.strftime(150, 100, id(f_clock), TextAlign::CENTER, "%H:%M", now);
it.strftime(150, 192, id(f_m), TextAlign::CENTER, "%A %d %B", now);
}
it.vertical_line(302, 24, 208);
const std::string &cond = id(weather_cond).state;
it.print(408, 64, id(icons_xl), TextAlign::CENTER, el512::weather_icon(cond));
if (!std::isnan(id(weather_temp).state))
it.printf(408, 140, id(f_big), TextAlign::CENTER, "%.1f°", id(weather_temp).state);
it.print(408, 184, id(f_m), TextAlign::CENTER, el512::weather_text(cond).c_str());
if (!std::isnan(id(weather_hum).state))
it.printf(408, 214, id(f_s), TextAlign::CENTER, "%.0f%% humidity %.0f km/h",
id(weather_hum).state, id(weather_wind).state);
# 2. Forecast (one column per day)
- id: page_forecast
lambda: |-
auto days = el512::split(id(forecast_days).state, ';');
const int n = std::min<int>(days.size(), 7);
if (n == 0) {
it.print(256, 128, id(f_m), TextAlign::CENTER, "No forecast (sensor.el512_forecast)");
return;
}
const int w = 512 / n;
for (int i = 0; i < n; i++) {
const int x = w * i + w / 2;
if (i > 0) it.vertical_line(w * i, 16, 224);
it.print(x, 10, id(f_m), TextAlign::TOP_CENTER, el512::field(days[i], 0).c_str());
it.print(x, 98, id(icons_l), TextAlign::CENTER, el512::weather_icon(el512::field(days[i], 1)));
it.printf(x, 140, id(f_l), TextAlign::TOP_CENTER, "%s°", el512::field(days[i], 2).c_str());
it.printf(x, 180, id(f_m), TextAlign::TOP_CENTER, "%s°", el512::field(days[i], 3).c_str());
it.print(x - 4, 224, id(icons_s), TextAlign::TOP_RIGHT, "\U000F054B");
it.printf(x - 2, 222, id(f_s), TextAlign::TOP_LEFT, "%s%%", el512::field(days[i], 4).c_str());
}
# 3. Calendar agenda
- id: page_agenda
lambda: |-
it.print(8, 4, id(icons_m), TextAlign::TOP_LEFT, "\U000F0E17");
it.print(48, 8, id(f_m), TextAlign::TOP_LEFT, "Agenda");
auto now = id(ha_time).now();
if (now.is_valid()) it.strftime(504, 10, id(f_s), TextAlign::TOP_RIGHT, "%A %d %B", now);
it.horizontal_line(0, 40, 512);
auto events = el512::split(id(agenda_events).state, ';');
if (events.empty()) {
it.print(256, 140, id(f_m), TextAlign::CENTER, "Nothing planned");
return;
}
int y = 48;
std::string last_day;
for (auto &e : events) {
if (y > 256 - 24) break;
const std::string day = el512::field(e, 0), time = el512::field(e, 1);
if (day != last_day) {
if (!last_day.empty()) it.horizontal_line(8, y - 2, 496);
it.print(8, y, id(f_m), TextAlign::TOP_LEFT, day.c_str());
last_day = day;
}
it.print(96, y, id(f_m), TextAlign::TOP_LEFT, time.empty() ? "all day" : time.c_str());
it.print(176, y, id(f_m), TextAlign::TOP_LEFT, el512::fit(id(f_m), el512::field(e, 2), 330).c_str());
y += 26;
}
# 4. Sensor dashboard and message line
- id: page_dashboard
lambda: |-
struct Tile { const char *name; float value; };
const Tile tiles[9] = {
{"Outside", id(weather_temp).state}, {"Woonkamer", id(t_woonkamer).state},
{"Voorkamer", id(t_voorkamer).state}, {"Gang", id(t_gang).state},
{"Patrick", id(t_patrick).state}, {"Badkamer", id(t_badkamer).state},
{"Stans & Paul", id(t_stans_paul).state}, {"Vloerverwarming", id(t_vloer).state},
{"Huis", id(t_huis).state},
};
for (int i = 0; i < 9; i++) {
const int x = (i % 3) * 171, y = (i / 3) * 68;
it.print(x + 10, y + 6, id(f_s), TextAlign::TOP_LEFT, tiles[i].name);
if (std::isnan(tiles[i].value))
it.print(x + 10, y + 24, id(f_l), TextAlign::TOP_LEFT, "--");
else
it.printf(x + 10, y + 24, id(f_l), TextAlign::TOP_LEFT, "%.1f°", tiles[i].value);
}
it.vertical_line(170, 4, 196);
it.vertical_line(341, 4, 196);
it.horizontal_line(0, 210, 512);
const std::string &msg = id(panel_message).state;
if (!msg.empty()) {
it.print(256, 234, id(f_m), TextAlign::CENTER, el512::fit(id(f_m), msg, 496).c_str());
} else {
const float net = id(grid_import).state - id(grid_export).state;
it.printf(256, 234, id(f_s), TextAlign::CENTER, "Solar %s House %s Grid %s",
el512::watts(id(solar_power).state).c_str(), el512::watts(id(house_power).state).c_str(),
el512::watts(net).c_str());
}
# 5. Now playing (whichever media player plays)
- id: page_media
lambda: |-
auto f = el512::split(id(np_info).state, '|'); // title|artist|album|player|pos|dur
if (f.size() < 6 || id(np_state).state == "idle") {
it.print(256, 118, id(icons_m), TextAlign::CENTER, "\U000F0387");
it.print(256, 160, id(f_m), TextAlign::CENTER, "Nothing playing");
return;
}
it.print(8, 6, id(icons_m), TextAlign::TOP_LEFT, "\U000F040A");
it.print(48, 12, id(f_s), TextAlign::TOP_LEFT, el512::fit(id(f_s), f[3], 450).c_str());
it.print(8, 56, id(f_l), TextAlign::TOP_LEFT, el512::fit(id(f_l), f[0], 496).c_str());
it.print(8, 104, id(f_m), TextAlign::TOP_LEFT, el512::fit(id(f_m), f[1], 496).c_str());
it.print(8, 136, id(f_s), TextAlign::TOP_LEFT, el512::fit(id(f_s), f[2], 496).c_str());
const int dur = atoi(f[5].c_str());
int pos = atoi(f[4].c_str()) + (int) ((millis() - id(np_updated)) / 1000);
if (dur > 0) {
pos = std::min(pos, dur);
it.rectangle(8, 190, 496, 14);
it.filled_rectangle(8, 190, 496 * pos / dur, 14);
it.print(8, 212, id(f_s), TextAlign::TOP_LEFT, el512::mmss(pos).c_str());
it.print(504, 212, id(f_s), TextAlign::TOP_RIGHT, el512::mmss(dur).c_str());
}
# 6. Plex: streams, server status (Plex + Tautulli integrations)
- id: page_plex
lambda: |-
const bool online = id(plex_server).state != "unavailable" && !id(plex_server).state.empty();
it.print(8, 4, id(icons_m), TextAlign::TOP_LEFT, "\U000F06BA");
it.print(48, 8, id(f_m), TextAlign::TOP_LEFT, "Plex");
it.print(504, 4, id(icons_m), TextAlign::TOP_RIGHT, online ? "\U000F048D" : "\U000F048E");
std::string ver = id(plex_version).state;
ver = ver.substr(0, ver.find('-'));
it.printf(462, 10, id(f_s), TextAlign::TOP_RIGHT, "%s%s%s", online ? "online" : "offline",
ver.empty() ? "" : " v", ver.c_str());
if (id(plex_update).state == "on")
it.print(300, 10, id(f_s), TextAlign::TOP_RIGHT, "update available");
it.horizontal_line(0, 40, 512);
// Left: stream count and bandwidth
const float n = id(plex_watching).state;
it.printf(70, 100, id(f_big), TextAlign::CENTER, "%.0f", std::isnan(n) ? 0.0f : n);
it.print(70, 134, id(f_s), TextAlign::TOP_CENTER, n == 1 ? "stream" : "streams");
if (!std::isnan(id(tautulli_bandwidth).state))
it.printf(70, 170, id(f_m), TextAlign::TOP_CENTER, "%.1f Mbit/s", id(tautulli_bandwidth).state / 1000.0f);
it.vertical_line(140, 48, 200);
// Right: who is watching what
auto streams = el512::split(id(plex_streams).state, ';');
if (streams.empty()) {
it.print(326, 140, id(f_m), TextAlign::CENTER, online ? "Nobody is watching" : "Server offline");
return;
}
int y = 50;
for (auto &s : streams) {
if (y > 256 - 48) break;
it.print(152, y, id(f_s), TextAlign::TOP_LEFT, el512::fit(id(f_s), el512::field(s, 0), 352).c_str());
it.print(152, y + 18, id(f_m), TextAlign::TOP_LEFT, el512::fit(id(f_m), el512::field(s, 1), 352).c_str());
y += 50;
}
# 7. Solar: production now and today, house and grid, graph of the last 16 h
- id: page_solar
lambda: |-
it.print(8, 8, id(icons_m), TextAlign::TOP_LEFT, "\U000F0A72");
const float sp = std::max(0.0f, id(solar_power).state);
it.print(50, 0, id(f_big), TextAlign::TOP_LEFT, el512::watts(sp).c_str());
if (!std::isnan(id(solar_today).state))
it.printf(50, 54, id(f_s), TextAlign::TOP_LEFT, "today %.2f kWh", id(solar_today).state / 1000.0f);
it.print(300, 8, id(icons_m), TextAlign::TOP_LEFT, "\U000F06A1");
it.print(340, 12, id(f_m), TextAlign::TOP_LEFT, el512::watts(id(house_power).state).c_str());
const float net = id(grid_import).state - id(grid_export).state;
it.print(300, 48, id(icons_m), TextAlign::TOP_LEFT, "\U000F0D3E");
it.print(340, 52, id(f_m), TextAlign::TOP_LEFT, el512::watts(std::fabs(net)).c_str());
it.print(440, 48, id(icons_m), TextAlign::TOP_LEFT, net > 0 ? "\U000F072E" : "\U000F0737");
it.graph(8, 128, id(solar_graph));
it.print(12, 110, id(f_s), TextAlign::TOP_LEFT, "last 16 h");
# 8. Portal end credits (started from HA; returns to the previous page)
- id: page_still_alive
lambda: |-
id(panel).draw_still_alive();