Files
planar-EL512.256-H/esphome/el512-panel.yaml
T
mangavalkandClaude Opus 5.5 50ee17b3f6 Add four animated Cyberpunk-style ESPHome pages
Netrunner HUD, self-solving breach protocol, optics boot-and-scan and a
split HUD with a mini breach matrix, all in the page rotation. The el512
display gains animated_pages / animation_interval (redraw ~15 fps while
shown), page_age_ms() as animation clock, and shift_rows() / invert_rect()
for glitch effects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:29:12 +02:00

636 lines
23 KiB
YAML

# 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}
on_boot:
- lambda: |-
id(cp_fonts) = {id(f_cp_title), id(f_cp_label), id(f_cp_value), id(f_cp_huge), id(f_cp_mono), id(f_cp_code)};
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"
# Cyberpunk pages: their fonts and the home data they show.
- id: cp_fonts
type: el512::cyber::Fonts
- id: cp_home
type: el512::cyber::HomeData
# --- 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
# Cyberpunk pages
- file: "gfonts://Orbitron@700"
id: f_cp_title
size: 18
glyphs: " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/_-.:>"
- file: "gfonts://Rajdhani@600"
id: f_cp_label
size: 18
glyphsets: [GF_Latin_Core]
- file: "gfonts://Rajdhani@700"
id: f_cp_value
size: 32
glyphs: " 0123456789.,:-+%°CkWh"
- file: "gfonts://Rajdhani@700"
id: f_cp_huge
size: 104
glyphs: "0123456789:"
- file: "gfonts://Share Tech Mono"
id: f_cp_mono
size: 14
glyphsets: [GF_Latin_Core]
- file: "gfonts://Share Tech Mono"
id: f_cp_code
size: 22
glyphs: " 0123456789ABCDEF."
# --- 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
- Cyber HUD
- Cyber Breach
- Cyber Scan
- Cyber Split
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);
else if (x == "Cyber HUD") p = id(page_cyber_hud);
else if (x == "Cyber Breach") p = id(page_cyber_breach);
else if (x == "Cyber Scan") p = id(page_cyber_scan);
else if (x == "Cyber Split") p = id(page_cyber_split);
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 < 12; 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();
# Home data for the Cyberpunk pages.
- interval: 1s
then:
- lambda: |-
auto &d = id(cp_home);
d.weather = id(weather_cond).state;
d.temp = id(weather_temp).state;
d.humidity = id(weather_hum).state;
d.wind = id(weather_wind).state;
d.solar = id(solar_power).state;
d.house = id(house_power).state;
d.grid = id(grid_import).state - id(grid_export).state;
d.rooms[0] = {"Woonkamer", id(t_woonkamer).state};
d.rooms[1] = {"Voorkamer", id(t_voorkamer).state};
d.rooms[2] = {"Patrick", id(t_patrick).state};
d.rooms[3] = {"Badkamer", id(t_badkamer).state};
# --- 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
# Redrawn every animation_interval (~15 fps) while shown.
animated_pages: [page_cyber_hud, page_cyber_breach, page_cyber_scan, page_cyber_split]
animation_interval: 66ms
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";
else if (to == id(page_cyber_hud)) name = "Cyber HUD";
else if (to == id(page_cyber_breach)) name = "Cyber Breach";
else if (to == id(page_cyber_scan)) name = "Cyber Scan";
else if (to == id(page_cyber_split)) name = "Cyber Split";
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();
# 9-12. Cyberpunk-style animated pages (components/el512/cyberpunk.cpp)
- id: page_cyber_hud
lambda: |-
el512::cyber::netrunner_hud(*id(panel), id(cp_fonts), id(cp_home), id(ha_time).now());
- id: page_cyber_breach
lambda: |-
el512::cyber::breach_protocol(*id(panel), id(cp_fonts));
- id: page_cyber_scan
lambda: |-
el512::cyber::optics_scan(*id(panel), id(cp_fonts), id(cp_home), id(ha_time).now());
- id: page_cyber_split
lambda: |-
el512::cyber::split_hud(*id(panel), id(cp_fonts), id(cp_home), id(ha_time).now());