Files
planar-EL512.256-H/esphome/el512-panel.yaml
T
mangavalkandClaude Opus 5.5 4c6286e794 Include Still Alive in the shuffled page rotation
The rotation already waits while the show plays, and the show returns to
the page it came from when it ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:09:38 +02:00

800 lines
30 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)};
id(ks_fonts) = {id(f_ks_title), id(f_ks_big), id(f_ks_text), id(f_ks_pixel), id(f_ks_pixel_l), id(f_ks_jp),
id(f_ks_jp_big), id(icons_l)};
id(sg_fonts) = {id(f_sg_nixie), id(f_sg_title), id(f_cp_mono), id(f_cp_code), id(f_ks_pixel), id(f_sg_jp)};
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"
- id: plex_updated
type: uint32_t
initial_value: "0"
# Themed pages (Cyberpunk, KonoSuba, Steins;Gate): their fonts and the home data they show.
- id: cp_fonts
type: el512::cyber::Fonts
- id: ks_fonts
type: el512::konosuba::Fonts
- id: sg_fonts
type: el512::steins::Fonts
- id: cp_home
type: el512::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
- "\U000F040A" # play
- "\U000F03E4" # pause
# 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.%"
# KonoSuba pages
- file: "gfonts://Cinzel@700"
id: f_ks_title
size: 22
glyphs: " ABCDEFGHIJKLMNOPQRSTUVWXYZ'!"
- file: "gfonts://Cinzel Decorative@900"
id: f_ks_big
size: 44
glyphs: "EXPLOSIN!"
- file: "gfonts://Nunito@700"
id: f_ks_text
size: 16
glyphsets: [GF_Latin_Core]
- file: "gfonts://Press Start 2P"
id: f_ks_pixel
size: 8
glyphsets: [GF_Latin_Core]
- file: "gfonts://Press Start 2P"
id: f_ks_pixel_l
size: 16
glyphs: " 0123456789:!ABCDEFGHIJKLMNOPQRSTUVWXYZ"
- file: "gfonts://Noto Sans JP@700"
id: f_ks_jp
size: 20
glyphs: "冒険者ギルドカー戦闘" # each character once
- file: "gfonts://Dela Gothic One"
id: f_ks_jp_big
size: 40
glyphs: "爆裂魔法"
# Steins;Gate pages (also use f_cp_mono, f_cp_code and f_ks_pixel)
- file: "gfonts://Nixie One"
id: f_sg_nixie
size: 110
glyphs: "0123456789."
- file: "gfonts://Special Elite"
id: f_sg_title
size: 20
glyphsets: [GF_Latin_Core]
- file: "gfonts://Noto Sans JP@700"
id: f_sg_jp
size: 18
glyphs: "未来ガジェット研究所世界線"
# --- 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
on_value:
- lambda: id(plex_updated) = millis();
- 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
- KonoSuba Explosion
- KonoSuba Guild
- KonoSuba Card
- KonoSuba Battle
- Divergence Meter
- D-Mail
- Gadget Lab
- World Lines
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);
else if (x == "KonoSuba Explosion") p = id(page_ks_explosion);
else if (x == "KonoSuba Guild") p = id(page_ks_guild);
else if (x == "KonoSuba Card") p = id(page_ks_card);
else if (x == "KonoSuba Battle") p = id(page_ks_battle);
else if (x == "Divergence Meter") p = id(page_sg_meter);
else if (x == "D-Mail") p = id(page_sg_dmail);
else if (x == "Gadget Lab") p = id(page_sg_lab);
else if (x == "World Lines") p = id(page_sg_world);
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 in a random order, reshuffled every hour (Still Alive included:
# rotation waits while it plays); Now playing is skipped 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;
static std::vector<display::DisplayPage *> order;
static size_t pos = 0;
static int shuffled_hour = -1;
auto now = id(ha_time).now();
const int hour = now.is_valid() ? now.hour : (int) (millis() / 3600000UL);
if (order.empty() || hour != shuffled_hour) {
shuffled_hour = hour;
order = {id(page_clock), id(page_forecast), id(page_agenda), id(page_dashboard), id(page_media),
id(page_plex), id(page_solar), id(page_cyber_hud), id(page_cyber_breach), id(page_cyber_scan),
id(page_cyber_split), id(page_ks_explosion), id(page_ks_guild), id(page_ks_card),
id(page_ks_battle), id(page_sg_meter), id(page_sg_dmail), id(page_sg_lab), id(page_sg_world),
id(page_still_alive)};
for (size_t i = order.size() - 1; i > 0; i--)
std::swap(order[i], order[random_uint32() % (i + 1)]);
pos = order.size() - 1; // the next step starts at the front
}
const std::string &np = id(np_state).state;
for (size_t n = 0; n < order.size(); n++) {
pos = (pos + 1) % order.size();
display::DisplayPage *p = order[pos];
if (p == id(page_media) && (np == "idle" || np.empty())) continue;
if (p == id(panel).get_active_page()) continue; // not twice in a row across a reshuffle
id(panel).show_page(p);
break;
}
id(panel).update();
# Home data for the themed 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.solar_today = id(solar_today).state;
d.agenda = id(agenda_events).state;
d.message = id(panel_message).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};
d.record(id(ha_time).now());
# --- 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,
page_ks_explosion, page_ks_guild, page_ks_card, page_ks_battle,
page_sg_meter, page_sg_dmail, page_sg_lab, page_sg_world]
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";
else if (to == id(page_ks_explosion)) name = "KonoSuba Explosion";
else if (to == id(page_ks_guild)) name = "KonoSuba Guild";
else if (to == id(page_ks_card)) name = "KonoSuba Card";
else if (to == id(page_ks_battle)) name = "KonoSuba Battle";
else if (to == id(page_sg_meter)) name = "Divergence Meter";
else if (to == id(page_sg_dmail)) name = "D-Mail";
else if (to == id(page_sg_lab)) name = "Gadget Lab";
else if (to == id(page_sg_world)) name = "World Lines";
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;
}
// Per stream: play/pause, who, time, title and a progress bar
// (who|title|state|pos|dur; the server-sensor fallback has no state).
int y = 50;
for (auto &s : streams) {
if (y > 256 - 48) break;
auto f = el512::split(s, '|');
f.resize(5);
const bool playing = f[2] == "playing", paused = f[2] == "paused";
const int dur = atoi(f[4].c_str());
int pos = atoi(f[3].c_str());
if (playing) pos += (int) ((millis() - id(plex_updated)) / 1000);
pos = std::min(pos, dur);
int x = 152;
if (playing || paused) {
it.print(150, y - 1, id(icons_s), TextAlign::TOP_LEFT, paused ? "\U000F03E4" : "\U000F040A");
x = 172;
}
std::string when;
if (dur > 0) when = el512::mmss(pos) + " / " + el512::mmss(dur);
int tw = 0, tx, ty, th;
if (!when.empty()) {
it.get_text_bounds(0, 0, when.c_str(), id(f_s), TextAlign::TOP_LEFT, &tx, &ty, &tw, &th);
it.print(504, y, id(f_s), TextAlign::TOP_RIGHT, when.c_str());
}
it.print(x, y, id(f_s), TextAlign::TOP_LEFT, el512::fit(id(f_s), f[0], 504 - x - tw - 10).c_str());
it.print(152, y + 17, id(f_m), TextAlign::TOP_LEFT, el512::fit(id(f_m), f[1], 352).c_str());
if (dur > 0) {
const int w = 352 * pos / dur;
if (paused) {
// Paused: hollow bar with a dotted fill.
it.rectangle(152, y + 42, 352, 5);
for (int px = 154; px < 152 + w; px += 2) it.draw_pixel_at(px, y + 44);
} else {
it.horizontal_line(152, y + 44, 352);
it.filled_rectangle(152, y + 42, w, 5);
}
}
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());
# 13-16. KonoSuba pages (components/el512/konosuba.cpp)
- id: page_ks_explosion
lambda: |-
el512::konosuba::explosion(*id(panel), id(ks_fonts), id(cp_home), id(ha_time).now());
- id: page_ks_guild
lambda: |-
el512::konosuba::guild_board(*id(panel), id(ks_fonts), id(cp_home), id(ha_time).now());
- id: page_ks_card
lambda: |-
el512::konosuba::adventurer_card(*id(panel), id(ks_fonts), id(cp_home), id(ha_time).now());
- id: page_ks_battle
lambda: |-
el512::konosuba::party_battle(*id(panel), id(ks_fonts), id(cp_home));
# 17-20. Steins;Gate pages (components/el512/steins.cpp)
- id: page_sg_meter
lambda: |-
el512::steins::divergence_meter(*id(panel), id(sg_fonts), id(ha_time).now());
- id: page_sg_dmail
lambda: |-
el512::steins::d_mail(*id(panel), id(sg_fonts), id(cp_home));
- id: page_sg_lab
lambda: |-
el512::steins::gadget_lab(*id(panel), id(sg_fonts), id(cp_home));
- id: page_sg_world
lambda: |-
el512::steins::world_lines(*id(panel), id(sg_fonts), id(cp_home), id(ha_time).now());