Add a PC renderer for the themed pages; fix overlapping layouts

tools/render_pages.py compiles the page code natively with g++ against
ESPHome's own display and font code and the font bitmaps of the last
`esphome compile`, and draws pages to PNG at chosen moments with sample
data (normal, empty, alert, busy), as a contact sheet, or as one example
per page in docs/images/pages (now shown in the README).

Fixes found with it: Cyber HUD zone names ran into the temperatures,
the breach header label hit the tag and the column marker the matrix
label; KonoSuba subtitle, guild weather icon, cabbage text and all-day
deadline, and hills through the party; Steins;Gate D-mail recipient, the
microwave display, the gadget card value and world line labels; LCARS
room rows, readout bars and data cascade, warp core readouts, and the
captain's log weather wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-19 16:46:31 +02:00
1 parent 13a499fd97
commit d2e81a5a36
27 files changed
+627 -38

No files matched your search

+3
View File
@@ -37,3 +37,6 @@ esphome/*-local.yaml
# Python bytecode (ESPHome component) # Python bytecode (ESPHome component)
__pycache__/ __pycache__/
# tools/render_pages.py build folder and renders
.render/
+42 -1
View File
@@ -95,7 +95,7 @@ src/test_images/ test sketch: cycles through 12 test images (env esp32dev
esphome/ el512-panel.yaml (Home Assistant display), el512-smoke.yaml esphome/ el512-panel.yaml (Home Assistant display), el512-smoke.yaml
home_assistant/packages/ el512_panel.yaml: helper sensors for the ESPHome pages home_assistant/packages/ el512_panel.yaml: helper sensors for the ESPHome pages
tools/ import_still_alive.py, konosuba_sprites.py, steins_sprites.py, tools/ import_still_alive.py, konosuba_sprites.py, steins_sprites.py,
trek_sprites.py trek_sprites.py, render_pages.py (+ render/: PC renderer)
test/ unit tests (placeholder) test/ unit tests (placeholder)
docs/ datasheet and notes docs/ datasheet and notes
``` ```
@@ -198,6 +198,11 @@ endorsed by Valve.
## Cyberpunk pages ## Cyberpunk pages
| | |
|---|---|
| ![Cyber HUD](docs/images/pages/cyber_hud.png) | ![Cyber Breach](docs/images/pages/cyber_breach.png) |
| ![Cyber Scan](docs/images/pages/cyber_scan.png) | ![Cyber Split](docs/images/pages/cyber_split.png) |
Four animated ESPHome pages in a Cyberpunk look, drawn by Four animated ESPHome pages in a Cyberpunk look, drawn by
[components/el512/cyberpunk.cpp](components/el512/cyberpunk.cpp) with Orbitron, Rajdhani [components/el512/cyberpunk.cpp](components/el512/cyberpunk.cpp) with Orbitron, Rajdhani
and Share Tech Mono (Google Fonts). All of them take part in the rotation: and Share Tech Mono (Google Fonts). All of them take part in the rotation:
@@ -224,6 +229,11 @@ with or endorsed by CD PROJEKT RED.
## KonoSuba pages ## KonoSuba pages
| | |
|---|---|
| ![KonoSuba Explosion](docs/images/pages/ks_explosion.png) | ![KonoSuba Guild](docs/images/pages/ks_guild.png) |
| ![KonoSuba Card](docs/images/pages/ks_card.png) | ![KonoSuba Battle](docs/images/pages/ks_battle.png) |
Four animated pages around the KonoSuba party, drawn by Four animated pages around the KonoSuba party, drawn by
[components/el512/konosuba.cpp](components/el512/konosuba.cpp) with Cinzel, Nunito, [components/el512/konosuba.cpp](components/el512/konosuba.cpp) with Cinzel, Nunito,
Press Start 2P, Noto Sans JP and Dela Gothic One (Google Fonts), all in the rotation: Press Start 2P, Noto Sans JP and Dela Gothic One (Google Fonts), all in the rotation:
@@ -256,6 +266,11 @@ affiliated with or endorsed by them.
## Steins;Gate pages ## Steins;Gate pages
| | |
|---|---|
| ![Divergence Meter](docs/images/pages/sg_meter.png) | ![D-Mail](docs/images/pages/sg_dmail.png) |
| ![Gadget Lab](docs/images/pages/sg_lab.png) | ![World Lines](docs/images/pages/sg_world.png) |
Four animated pages from the Future Gadget Lab, drawn by Four animated pages from the Future Gadget Lab, drawn by
[components/el512/steins.cpp](components/el512/steins.cpp) with Nixie One, Special [components/el512/steins.cpp](components/el512/steins.cpp) with Nixie One, Special
Elite and Noto Sans JP (plus fonts shared with the other themed pages), all in the Elite and Noto Sans JP (plus fonts shared with the other themed pages), all in the
@@ -282,6 +297,11 @@ from the series is used. Steins;Gate is © MAGES. and its other rights holders;
## Star Trek pages ## Star Trek pages
| | |
|---|---|
| ![LCARS Status](docs/images/pages/st_lcars.png) | ![Duty Roster](docs/images/pages/st_roster.png) |
| ![Warp Core](docs/images/pages/st_warp.png) | ![Captain's Log](docs/images/pages/st_log.png) |
Four animated pages aboard a Lower Decks-era ship, drawn by Four animated pages aboard a Lower Decks-era ship, drawn by
[components/el512/trek.cpp](components/el512/trek.cpp) with Antonio (standing in for [components/el512/trek.cpp](components/el512/trek.cpp) with Antonio (standing in for
the LCARS typeface), all in the rotation: the LCARS typeface), all in the rotation:
@@ -309,6 +329,27 @@ These pages are fan art: the pixel art, layouts and texts are original, and no m
from the series is used. Star Trek and Star Trek: Lower Decks are trademarks of CBS from the series is used. Star Trek and Star Trek: Lower Decks are trademarks of CBS
Studios Inc.; this project is not affiliated with or endorsed by them. Studios Inc.; this project is not affiliated with or endorsed by them.
## Rendering pages on the PC
[tools/render_pages.py](tools/render_pages.py) draws the themed pages to PNG without
the panel, to check layouts before flashing and to make the images above. It compiles
the page code natively with g++ against ESPHome's own display and font code (from the
installed `esphome` package) and the exact font bitmaps of the last `esphome compile`,
so text sizes and positions match the device. Pages are drawn at chosen moments after
they were shown, with sample data:
```bash
esphome compile esphome/el512-panel-local.yaml # once, and after font changes
python tools/render_pages.py # all pages -> .render/out
python tools/render_pages.py --page st_lcars --at 0,2000 --scenario alert
python tools/render_pages.py --scenario busy --sheet # long names, high values
python tools/render_pages.py --scenario empty # no data yet
python tools/render_pages.py --examples # docs/images/pages/*.png
```
It needs a C++20 g++ (MinGW-w64 works on Windows) and Pillow. The host stand-in for
the display and the sample data are in [tools/render/](tools/render/).
## Using the driver ## Using the driver
```cpp ```cpp
+7 -6
View File
@@ -404,9 +404,10 @@ void netrunner_hud(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime
cut_frame(it, 254, 150, 258, 86); cut_frame(it, 254, 150, 258, 86);
tag(it, f.mono, 254, 150, 14, "ZONES"); tag(it, f.mono, 254, 150, 14, "ZONES");
for (int i = 0; i < 4; i++) { for (int i = 0; i < 4; i++) {
const int x = 264 + (i % 2) * 124, y = 192 + (i / 2) * 34; // Name on top, temperature below it.
it.print(x, y, f.label, TextAlign::BASELINE_LEFT, el512::fit(f.label, d.rooms[i].name, 62).c_str()); const int x = 264 + (i % 2) * 124, y = 164 + (i / 2) * 36;
it.print(x + 116, y, f.value, TextAlign::BASELINE_RIGHT, temp_text(d.rooms[i].value).c_str()); it.print(x, y + 11, f.mono, TextAlign::BASELINE_LEFT, el512::fit(f.mono, upper(d.rooms[i].name), 114).c_str());
it.print(x, y + 33, f.value, TextAlign::BASELINE_LEFT, temp_text(d.rooms[i].value).c_str());
} }
// Bottom: ticker. // Bottom: ticker.
@@ -432,7 +433,7 @@ void breach_protocol(EL512Display &it, const Fonts &f) {
// Header and timer. // Header and timer.
tag(it, f.title, 0, 0, 24, "BREACH PROTOCOL"); tag(it, f.title, 0, 0, 24, "BREACH PROTOCOL");
it.print(420, 18, f.label, TextAlign::BASELINE_RIGHT, "BREACH TIME REMAINING"); it.print(420, 18, f.label, TextAlign::BASELINE_RIGHT, "TIME REMAINING");
cut_frame(it, 428, 0, 84, 26, 6); cut_frame(it, 428, 0, 84, 26, 6);
const bool low = v.remain < 10.0f && (t / 250) % 2; const bool low = v.remain < 10.0f && (t / 250) % 2;
if (!low) if (!low)
@@ -441,8 +442,8 @@ void breach_protocol(EL512Display &it, const Fonts &f) {
// Code matrix. // Code matrix.
cut_frame(it, 0, 40, 296, 216); cut_frame(it, 0, 40, 296, 216);
it.print(10, 54, f.mono, TextAlign::BASELINE_LEFT, "CODE MATRIX"); it.print(10, 52, f.mono, TextAlign::BASELINE_LEFT, "CODE MATRIX");
draw_matrix(it, {10, 62, 46, 31, f.code}, p, v); draw_matrix(it, {10, 64, 46, 31, f.code}, p, v);
// Buffer. // Buffer.
it.print(304, 54, f.mono, TextAlign::BASELINE_LEFT, "BUFFER"); it.print(304, 54, f.mono, TextAlign::BASELINE_LEFT, "BUFFER");
+6
View File
@@ -96,6 +96,12 @@ inline std::string upper(std::string s) {
return s; return s;
} }
inline std::string lower(std::string s) {
for (auto &c : s)
c = (char) tolower((unsigned char) c);
return s;
}
inline int text_width(display::Display &it, display::BaseFont *font, const char *text) { inline int text_width(display::Display &it, display::BaseFont *font, const char *text) {
int x, y, w, h; int x, y, w, h;
it.get_text_bounds(0, 0, text, font, display::TextAlign::TOP_LEFT, &x, &y, &w, &h); it.get_text_bounds(0, 0, text, font, display::TextAlign::TOP_LEFT, &x, &y, &w, &h);
+7 -7
View File
@@ -215,7 +215,7 @@ void explosion(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now)
// Title and clock (hidden while the sky is on fire). // Title and clock (hidden while the sky is on fire).
if (!blasting) { if (!blasting) {
it.print(8, 4, f.jp_big, TextAlign::TOP_LEFT, "爆裂魔法"); it.print(8, 4, f.jp_big, TextAlign::TOP_LEFT, "爆裂魔法");
it.print(10, 52, f.pixel, TextAlign::TOP_LEFT, "EXPLOSION MAGIC"); it.print(10, 56, f.pixel, TextAlign::TOP_LEFT, "EXPLOSION MAGIC");
if (now.is_valid()) if (now.is_valid())
it.strftime(504, 6, f.pixel_l, TextAlign::TOP_RIGHT, "%H:%M", now); it.strftime(504, 6, f.pixel_l, TextAlign::TOP_RIGHT, "%H:%M", now);
} }
@@ -372,7 +372,7 @@ void guild_board(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime no
if (quests.size() == 3) if (quests.size() == 3)
break; break;
const std::string day = field(e, 0), time = field(e, 1); const std::string day = field(e, 0), time = field(e, 1);
quests.push_back({field(e, 2), upper(day) + " " + (time.empty() ? "ALL DAY" : time), true, day == today}); quests.push_back({field(e, 2), upper(day) + (time.empty() ? "" : " " + time), true, day == today});
} }
static const char *const STANDING[][2] = {{"Exterminate five giant toads", "BEFORE DUSK"}, static const char *const STANDING[][2] = {{"Exterminate five giant toads", "BEFORE DUSK"},
{"Escort a merchant cart to the next town", "THIS WEEK"}, {"Escort a merchant cart to the next town", "THIS WEEK"},
@@ -412,13 +412,13 @@ void guild_board(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime no
// Hazard notice (weather) and the cabbage bounty (solar). // Hazard notice (weather) and the cabbage bounty (solar).
note(it, 386, 48, 116, 94); note(it, 386, 48, 116, 94);
it.print(444, 60, f.pixel, TextAlign::TOP_CENTER, "HAZARD"); it.print(444, 60, f.pixel, TextAlign::TOP_CENTER, "HAZARD");
it.print(416, 102, f.weather, TextAlign::CENTER, weather_icon(d.weather)); it.print(416, 98, f.weather, TextAlign::CENTER, weather_icon(d.weather));
it.print(492, 94, f.text, TextAlign::BASELINE_RIGHT, std::isnan(d.temp) ? "--" : str("%.0f°", d.temp).c_str()); it.print(492, 94, f.text, TextAlign::BASELINE_RIGHT, std::isnan(d.temp) ? "--" : str("%.0f°", d.temp).c_str());
it.print(444, 128, f.pixel, TextAlign::TOP_CENTER, fit(f.pixel, upper(weather_text(d.weather)), 100).c_str()); it.print(444, 131, f.pixel, TextAlign::TOP_CENTER, fit(f.pixel, upper(weather_text(d.weather)), 100).c_str());
note(it, 386, 150, 116, 94); note(it, 386, 150, 116, 94);
it.print(444, 162, f.pixel, TextAlign::TOP_CENTER, "BOUNTY"); it.print(444, 162, f.pixel, TextAlign::TOP_CENTER, "BOUNTY");
it.print(444, 176, f.text, TextAlign::TOP_CENTER, "Cabbage harvest"); it.print(444, 176, f.text, TextAlign::TOP_CENTER, "Cabbage crop");
it.print(444, 198, f.text, TextAlign::TOP_CENTER, it.print(444, 198, f.text, TextAlign::TOP_CENTER,
std::isnan(d.solar_today) ? "--" : str("%.1f kWh", d.solar_today / 1000.0f).c_str()); std::isnan(d.solar_today) ? "--" : str("%.1f kWh", d.solar_today / 1000.0f).c_str());
it.printf(444, 222, f.pixel, TextAlign::TOP_CENTER, "NOW %s", upper(watts_text(d.solar)).c_str()); it.printf(444, 222, f.pixel, TextAlign::TOP_CENTER, "NOW %s", upper(watts_text(d.solar)).c_str());
@@ -535,8 +535,8 @@ void party_battle(EL512Display &it, const Fonts &f, const HomeData &d) {
// Scenery. // Scenery.
it.print(8, 6, f.jp, TextAlign::TOP_LEFT, "戦闘"); it.print(8, 6, f.jp, TextAlign::TOP_LEFT, "戦闘");
it.printf(56, 12, f.pixel, TextAlign::TOP_LEFT, "ROUND %u", (unsigned) (round + 1)); it.printf(56, 12, f.pixel, TextAlign::TOP_LEFT, "ROUND %u", (unsigned) (round + 1));
arc(it, 110, GROUND + 70, 110, -52, 52); // A hill between the party and the toad, clear of both.
arc(it, 300, GROUND + 90, 120, -40, 40); arc(it, 270, GROUND + 50, 80, -46, 46);
it.horizontal_line(0, GROUND, W); it.horizontal_line(0, GROUND, W);
for (int i = 0; i < 18; i++) { for (int i = 0; i < 18; i++) {
const int x = (int) (hash(i, 21) % W); const int x = (int) (hash(i, 21) % W);
+24 -17
View File
@@ -223,7 +223,7 @@ void d_mail(EL512Display &it, const Fonts &f, const HomeData &d) {
for (int b = 0; b < 4; b++) for (int b = 0; b < 4; b++)
it.filled_rectangle(146 + b * 6, 26 - b * 2 - 2, 4, b * 2 + 2, COLOR_OFF); it.filled_rectangle(146 + b * 6, 26 - b * 2 - 2, 4, b * 2 + 2, COLOR_OFF);
if (c < SEND) { if (c < SEND) {
it.print(35, 34, f.pixel, TextAlign::TOP_LEFT, "TO: ME, 1 WEEK AGO"); it.print(35, 34, f.pixel, TextAlign::TOP_LEFT, "TO: ME, -1 WEEK");
it.horizontal_line(34, 45, 138); it.horizontal_line(34, 45, 138);
std::string body = c < TYPE_AT ? "" : typed(msg, c - TYPE_AT, CPS); std::string body = c < TYPE_AT ? "" : typed(msg, c - TYPE_AT, CPS);
if ((t / 300) % 2) if ((t / 300) % 2)
@@ -267,7 +267,11 @@ void d_mail(EL512Display &it, const Fonts &f, const HomeData &d) {
it.draw_pixel_at(x, y, COLOR_ON); it.draw_pixel_at(x, y, COLOR_ON);
it.printf(MX + 209, MY + 23, f.mono, TextAlign::CENTER, "0:%02u", (unsigned) ((ZAP + 300 - c) / 1000)); it.printf(MX + 209, MY + 23, f.mono, TextAlign::CENTER, "0:%02u", (unsigned) ((ZAP + 300 - c) / 1000));
} else { } else {
it.print(MX + 209, MY + 23, f.mono, TextAlign::CENTER, upper(watts_text(d.house)).c_str()); // The microwave's little display: "850W" or "12.0kW".
const std::string lcd = std::isnan(d.house) ? "--"
: d.house >= 1000 ? str("%.1fkW", d.house / 1000.0f)
: str("%.0fW", d.house);
it.print(MX + 209, MY + 23, f.mono, TextAlign::CENTER, lcd.c_str());
} }
// Turntable with a banana on it. // Turntable with a banana on it.
for (int a = 0; a < 360; a += 15) { for (int a = 0; a < 360; a += 15) {
@@ -374,17 +378,19 @@ void gadget_lab(EL512Display &it, const Fonts &f, const HomeData &d) {
// Detail card for the selected gadget. // Detail card for the selected gadget.
const Gadget &g = gadgets[sel]; const Gadget &g = gadgets[sel];
const uint32_t since = t % STEP; const uint32_t since = t % STEP;
it.rectangle(340, 38, 170, 128); // Number, name, value, then the description.
it.rectangle(342, 40, 166, 124); it.rectangle(340, 38, 170, 140);
it.printf(350, 46, f.title, TextAlign::TOP_LEFT, "NO.%s", g.no.c_str()); it.rectangle(342, 40, 166, 136);
it.print(502, 46, f.title, TextAlign::TOP_RIGHT, g.value.c_str()); it.printf(350, 44, f.title, TextAlign::TOP_LEFT, "NO.%s", g.no.c_str());
int y = 74; int y = 68;
for (const auto &l : wrap(it, f.mono, g.name, 150, 2)) { for (const auto &l : wrap(it, f.mono, g.name, 150, 2)) {
it.print(350, y, f.mono, TextAlign::TOP_LEFT, l.c_str()); it.print(350, y, f.mono, TextAlign::TOP_LEFT, l.c_str());
y += 15; y += 15;
} }
it.horizontal_line(350, y + 2, 150); it.print(350, y + 2, f.title, TextAlign::TOP_LEFT, g.value.c_str());
y += 8; y += 26;
it.horizontal_line(350, y, 150);
y += 5;
for (const auto &l : wrap(it, f.mono, typed(g.desc, since, 40), 150, 3)) { for (const auto &l : wrap(it, f.mono, typed(g.desc, since, 40), 150, 3)) {
it.print(350, y, f.mono, TextAlign::TOP_LEFT, l.c_str()); it.print(350, y, f.mono, TextAlign::TOP_LEFT, l.c_str());
y += 15; y += 15;
@@ -429,7 +435,7 @@ void world_lines(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime no
} }
for (int h = 0; h <= 24; h += 6) { for (int h = 0; h <= 24; h += 6) {
it.vertical_line(xmap(h), PY1, 4); it.vertical_line(xmap(h), PY1, 4);
it.printf(xmap(h), PY1 + 8, f.pixel, TextAlign::TOP_CENTER, "%02d:00", h % 24); it.printf(xmap(h), PY1 + 8, f.pixel, h == 24 ? TextAlign::TOP_RIGHT : TextAlign::TOP_CENTER, "%02d:00", h);
} }
// Attractor fields. // Attractor fields.
@@ -441,14 +447,15 @@ void world_lines(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime no
const int phase = (int) (t / 80); const int phase = (int) (t / 80);
for (const auto &fl : fields) { for (const auto &fl : fields) {
const int y = ymap(fl.div); const int y = ymap(fl.div);
if (fl.div == 1.048596f) { // Labels on the left, where the chart stays empty until sunrise: Beta's
// above its line, the others below theirs (Steins Gate and Beta are only a
// few pixels apart).
if (fl.div == 1.048596f)
it.horizontal_line(PX0 + 1, y, PX1 - PX0); it.horizontal_line(PX0 + 1, y, PX1 - PX0);
it.print(PX1, y - 3, f.pixel, TextAlign::BOTTOM_RIGHT, fl.name); else
} else {
dashed_hline(it, PX0 + 1, y, PX1 - PX0, 4, 4, phase); dashed_hline(it, PX0 + 1, y, PX1 - PX0, 4, 4, phase);
it.print(PX1, fl.div > 1.1f ? y - 3 : y + 3, f.pixel, fl.div > 1.1f ? TextAlign::BOTTOM_RIGHT : TextAlign::TOP_RIGHT, const bool above = fl.div > 1.1f;
fl.name); it.print(PX0 + 6, above ? y - 3 : y + 3, f.pixel, above ? TextAlign::BOTTOM_LEFT : TextAlign::TOP_LEFT, fl.name);
}
} }
// Today's world line, drawn in over the first 1.5 s. // Today's world line, drawn in over the first 1.5 s.
@@ -472,7 +479,7 @@ void world_lines(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime no
points++; points++;
} }
if (points == 0 && (t / 600) % 2) if (points == 0 && (t / 600) % 2)
it.print((PX0 + PX1) / 2, (PY0 + PY1) / 2 + 30, f.mono, TextAlign::CENTER, "COLLECTING WORLD LINE DATA..."); it.print((PX0 + PX1) / 2 - 40, PY1 - 10, f.mono, TextAlign::CENTER, "COLLECTING WORLD LINE DATA...");
// Where we are now, and the futures branching off it. // Where we are now, and the futures branching off it.
if (now.is_valid() && t >= 1500) { if (now.is_valid() && t >= 1500) {
+7 -7
View File
@@ -173,16 +173,16 @@ void lcars_status(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime n
for (int i = 0; i < 3; i++) { for (int i = 0; i < 3; i++) {
const int y = 48 + i * 24; const int y = 48 + i * 24;
block(it, f.small, 84, y, 66, 18, rows[i].name); block(it, f.small, 84, y, 66, 18, rows[i].name);
readout(it, 156, y + 4, 90, 10, rows[i].w / 5000.0f); readout(it, 156, y + 4, 80, 10, rows[i].w / 5000.0f);
it.print(298, y + 18, f.small, TextAlign::BOTTOM_RIGHT, kw(rows[i].w).c_str()); it.print(298, y + 18, f.small, TextAlign::BOTTOM_RIGHT, kw(rows[i].w).c_str());
} }
// Environmental. // Environmental.
it.print(84, 128, f.small, TextAlign::TOP_LEFT, "ENVIRONMENTAL DECKS 1-4"); it.print(84, 128, f.small, TextAlign::TOP_LEFT, "ENVIRONMENTAL DECKS 1-4");
for (int i = 0; i < 4; i++) { for (int i = 0; i < 4; i++) {
const int x = 84 + (i % 2) * 110, y = 150 + (i / 2) * 40; const int x = 84 + (i % 2) * 110, y = 148 + (i / 2) * 44;
it.print(x, y, f.small, TextAlign::TOP_LEFT, fit(f.small, upper(d.rooms[i].name), 104).c_str()); it.print(x, y, f.small, TextAlign::TOP_LEFT, fit(f.small, upper(d.rooms[i].name), 104).c_str());
it.print(x, y + 16, f.medium, TextAlign::TOP_LEFT, deg(d.rooms[i].value).c_str()); it.print(x, y + 14, f.medium, TextAlign::TOP_LEFT, deg(d.rooms[i].value).c_str());
} }
// Clock, sensors, fabrication bay. // Clock, sensors, fabrication bay.
@@ -200,7 +200,7 @@ void lcars_status(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime n
for (int col = 0; col < 2; col++) { for (int col = 0; col < 2; col++) {
for (int row = 0; row < 9; row++) { for (int row = 0; row < 9; row++) {
const uint32_t r = hash(col * 31 + row, (t / 180) + row * 3); const uint32_t r = hash(col * 31 + row, (t / 180) + row * 3);
const int x = 446 + col * 32, y = 96 + row * 12; const int x = 456 + col * 30, y = 96 + row * 12;
if (r % 5 == 0) if (r % 5 == 0)
continue; continue;
it.printf(x, y, f.pixel, TextAlign::TOP_LEFT, "%03u", (unsigned) (r % 1000)); it.printf(x, y, f.pixel, TextAlign::TOP_LEFT, "%03u", (unsigned) (r % 1000));
@@ -325,8 +325,8 @@ void warp_core(EL512Display &it, const Fonts &f, const HomeData &d) {
for (int i = 0; i < 3; i++) { for (int i = 0; i < 3; i++) {
const int y = 36 + i * 44; const int y = 36 + i * 44;
it.print(0, y, f.small, TextAlign::TOP_LEFT, rows[i].name); it.print(0, y, f.small, TextAlign::TOP_LEFT, rows[i].name);
it.print(204, y, f.small, TextAlign::TOP_RIGHT, rows[i].value.c_str()); it.print(196, y, f.small, TextAlign::TOP_RIGHT, rows[i].value.c_str());
readout(it, 0, y + 22, 200, 10, rows[i].frac); readout(it, 0, y + 22, 196, 10, rows[i].frac);
} }
it.print(0, 172, f.small, TextAlign::TOP_LEFT, "MATTER / ANTIMATTER 1:1"); it.print(0, 172, f.small, TextAlign::TOP_LEFT, "MATTER / ANTIMATTER 1:1");
it.print(0, 192, f.small, TextAlign::TOP_LEFT, "DILITHIUM MATRIX STABLE"); it.print(0, 192, f.small, TextAlign::TOP_LEFT, "DILITHIUM MATRIX STABLE");
@@ -409,7 +409,7 @@ void captains_log(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime n
std::string entry = "We are holding position at home."; std::string entry = "We are holding position at home.";
if (!std::isnan(d.temp)) if (!std::isnan(d.temp))
entry += str(" Outside it is %.0f degrees", d.temp) + entry += str(" Outside it is %.0f degrees", d.temp) +
(d.weather.empty() ? std::string(".") : " and " + weather_text(d.weather) + "."); (d.weather.empty() ? std::string(".") : " and " + lower(weather_text(d.weather)) + ".");
if (!std::isnan(d.solar)) if (!std::isnan(d.solar))
entry += " The solar collectors deliver " + watts(d.solar) + entry += " The solar collectors deliver " + watts(d.solar) +
(std::isnan(d.house) ? std::string(".") : " while the replicators draw " + watts(d.house) + "."); (std::isnan(d.house) ? std::string(".") : " while the replicators draw " + watts(d.house) + ".");
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

+104
View File
@@ -0,0 +1,104 @@
// Host stand-in for components/el512/el512_display.h, used by
// tools/render_pages.py to draw the themed pages on a PC: the same drawing
// surface (ESPHome's Display, 512 x 256, 1 bpp) and the same buffer effects,
// without the panel, DMA or ESPHome components.
#pragma once
#include <algorithm>
#include <cstdint>
#include <cstring>
#include "cyberpunk.h"
#include "el512_helpers.h"
#include "esphome/components/display/display.h"
#include "esphome/core/hal.h"
#include "konosuba.h"
#include "steins.h"
#include "trek.h"
namespace esphome::el512 {
class EL512Display : public display::Display {
public:
static constexpr int WIDTH = 512, HEIGHT = 256, ROW = WIDTH / 8;
uint8_t fb[ROW * HEIGHT]{}; // row-major, MSB = leftmost pixel (also PBM P4 order)
uint32_t shown_at{0}; // millis() when the page was "shown"
void update() override {}
display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_BINARY; }
void fill(Color color) override {
if (this->get_clipping().is_set()) {
Display::fill(color);
return;
}
memset(this->fb, color.is_on() ? 0xFF : 0x00, sizeof(this->fb));
}
void draw_pixel_at(int x, int y, Color color) override {
if (!this->get_clipping().inside(x, y))
return;
this->set(x, y, color.is_on());
}
uint32_t page_age_ms() { return millis() - this->shown_at; }
uint32_t page_shown_at() const { return this->shown_at; }
// Same effects as the device (el512_display.cpp).
void shift_rows(int y, int height, int dx) {
dx %= WIDTH;
if (dx < 0)
dx += WIDTH;
if (dx == 0)
return;
uint8_t src[ROW];
for (int r = std::max(y, 0); r < std::min(y + height, HEIGHT); r++) {
uint8_t *row = this->fb + r * ROW;
memcpy(src, row, ROW);
memset(row, 0, ROW);
for (int x = 0; x < WIDTH; x++) {
const int s = (x - dx + WIDTH) % WIDTH;
if (src[s >> 3] & (0x80 >> (s & 7)))
row[x >> 3] |= 0x80 >> (x & 7);
}
}
}
void invert_rect(int x, int y, int width, int height) {
for (int py = y; py < y + height; py++)
for (int px = x; px < x + width; px++)
if (px >= 0 && py >= 0 && px < WIDTH && py < HEIGHT)
this->set(px, py, !this->get(px, py));
}
void dither_mask(int level) {
if (level >= 16)
return;
static const uint8_t BAYER[4][4] = {{0, 8, 2, 10}, {12, 4, 14, 6}, {3, 11, 1, 9}, {15, 7, 13, 5}};
uint8_t masks[4];
for (int r = 0; r < 4; r++) {
masks[r] = 0;
for (int x = 0; x < 8; x++)
if (BAYER[r][x & 3] < level)
masks[r] |= 0x80 >> x;
}
for (int y = 0; y < HEIGHT; y++)
for (int i = 0; i < ROW; i++)
this->fb[y * ROW + i] &= masks[y & 3];
}
bool get(int x, int y) const { return this->fb[y * ROW + (x >> 3)] & (0x80 >> (x & 7)); }
void set(int x, int y, bool on) {
if (x < 0 || y < 0 || x >= WIDTH || y >= HEIGHT)
return;
uint8_t &b = this->fb[y * ROW + (x >> 3)];
if (on)
b |= 0x80 >> (x & 7);
else
b &= ~(0x80 >> (x & 7));
}
protected:
int get_width_internal() override { return WIDTH; }
int get_height_internal() override { return HEIGHT; }
};
} // namespace esphome::el512
+29
View File
@@ -0,0 +1,29 @@
// The parts of ESPHome's core that the display code links against but the
// renderer never uses: there is no component lifecycle, polling, MAC or RNG on
// the PC side.
#include <cstring>
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
namespace esphome {
void Component::setup() {}
void Component::loop() {}
void Component::dump_config() {}
float Component::get_setup_priority() const { return 0.0f; }
bool Component::can_proceed() { return true; }
void Component::call_setup() {}
PollingComponent::PollingComponent(uint32_t update_interval) : update_interval_(update_interval) {}
void PollingComponent::call_setup() {}
uint32_t PollingComponent::get_update_interval() const { return this->update_interval_; }
void PollingComponent::stop_poller() {}
bool random_bytes(uint8_t *data, size_t len) {
memset(data, 0x5A, len);
return true;
}
void get_mac_address_raw(uint8_t *mac) { memset(mac, 0, 6); }
} // namespace esphome
+157
View File
@@ -0,0 +1,157 @@
// Renders the themed ESPHome pages on a PC with ESPHome's own display and
// font code and the fonts of the last `esphome compile`. Built and run by
// tools/render_pages.py; see there for usage.
//
// render list page names and default moments
// render <out_dir> <scenario> <page>:<ms> ... one PBM per page and moment
//
// Scenarios: normal, empty (no data), alert (3D printer paused), busy (long
// names, high values, export).
#include <cstdio>
#include <functional>
#include <string>
#include <vector>
#include "el512_display.h"
#include "fonts.h"
using namespace esphome;
using namespace esphome::el512;
static uint32_t g_millis = 0;
namespace esphome {
uint32_t millis() { return g_millis; }
uint64_t millis_64() { return g_millis; }
uint32_t micros() { return g_millis * 1000; }
void delay(uint32_t) {}
void delayMicroseconds(uint32_t) {}
} // namespace esphome
// Monday 2026-10-19 14:37:25 (UTC, so renders don't depend on the PC's zone).
static ESPTime sample_time() { return ESPTime::from_epoch_utc(1792420645); }
static HomeData sample(const std::string &scenario) {
HomeData d;
if (scenario == "empty")
return d;
d.weather = "partlycloudy";
d.temp = 12.4f;
d.humidity = 81;
d.wind = 14;
d.solar = 3420;
d.solar_today = 9800;
d.house = 1240;
d.grid = -2180;
d.agenda = "Mon 19|16:00|Dentist appointment;Tue 20||Holiday;Wed 21|09:30|Team meeting about the roadmap;"
"Thu 22|19:00|Dinner at Stans & Paul";
d.message = "";
d.printer = scenario == "alert" ? "paused" : "printing";
d.rooms[0] = {"Woonkamer", 21.3f};
d.rooms[1] = {"Voorkamer", 19.8f};
d.rooms[2] = {"Patrick", 20.5f};
d.rooms[3] = {"Badkamer", 22.1f};
if (scenario == "busy") {
d.weather = "lightning-rainy";
d.temp = -12.6f;
d.solar = 12450;
d.solar_today = 48700;
d.house = 11980;
d.grid = 8520;
d.humidity = 100;
d.wind = 104;
d.message = "Remember to take the bins out tonight before 22:00!";
d.agenda = "Mon 19|16:00|Very long appointment title that keeps going and going to test wrapping;"
"Tue 20||Holiday;Wed 21|09:30|Quarterly planning session with the entire department;"
"Thu 22|19:00|Dinner";
d.rooms[0] = {"Vloerverwarming", 23.9f};
d.rooms[1] = {"Stans & Paul", 18.2f};
}
// Today's solar curve up to now (14:37), for the world line chart.
for (int s = 7 * 4; s <= 14 * 4 + 2; s++) {
const float h = s / 4.0f;
d.solar_slots[s] = std::max(0.0f, 4200.0f * std::sin((h - 7.0f) / 12.0f * 3.14159f));
}
return d;
}
struct Page {
const char *name;
std::vector<uint32_t> moments; // ms after the page was shown; the first is its example
std::function<void(EL512Display &, const HomeData &, ESPTime)> draw;
};
static std::vector<Page> pages() {
const cyber::Fonts cp{f_cp_title, f_cp_label, f_cp_value, f_cp_huge, f_cp_mono, f_cp_code};
const konosuba::Fonts ks{f_ks_title, f_ks_big, f_ks_text, f_ks_pixel, f_ks_pixel_l, f_ks_jp, f_ks_jp_big, icons_l};
const steins::Fonts sg{f_sg_nixie, f_sg_title, f_cp_mono, f_cp_code, f_ks_pixel, f_sg_jp};
const trek::Fonts st{f_st_small, f_st_medium, f_st_large, f_ks_text, f_ks_pixel};
return {
{"cyber_hud", {2000}, [=](auto &it, auto &d, auto now) { cyber::netrunner_hud(it, cp, d, now); }},
{"cyber_breach", {4500, 9000}, [=](auto &it, auto &, auto) { cyber::breach_protocol(it, cp); }},
{"cyber_scan", {7000, 2500}, [=](auto &it, auto &d, auto now) { cyber::optics_scan(it, cp, d, now); }},
{"cyber_split", {4500}, [=](auto &it, auto &d, auto now) { cyber::split_hud(it, cp, d, now); }},
{"ks_explosion", {6900, 800, 5200, 10500, 15000},
[=](auto &it, auto &d, auto now) { konosuba::explosion(it, ks, d, now); }},
{"ks_guild", {3000}, [=](auto &it, auto &d, auto now) { konosuba::guild_board(it, ks, d, now); }},
{"ks_card", {2000}, [=](auto &it, auto &d, auto now) { konosuba::adventurer_card(it, ks, d, now); }},
{"ks_battle", {2500, 10500, 18300, 20000}, [=](auto &it, auto &d, auto) { konosuba::party_battle(it, ks, d); }},
{"sg_meter", {3000, 11000}, [=](auto &it, auto &, auto now) { steins::divergence_meter(it, sg, now); }},
{"sg_dmail", {8000, 3000, 13500}, [=](auto &it, auto &d, auto) { steins::d_mail(it, sg, d); }},
{"sg_lab", {1500}, [=](auto &it, auto &d, auto) { steins::gadget_lab(it, sg, d); }},
{"sg_world", {3000}, [=](auto &it, auto &d, auto now) { steins::world_lines(it, sg, d, now); }},
{"st_lcars", {2000}, [=](auto &it, auto &d, auto now) { trek::lcars_status(it, st, d, now); }},
{"st_roster", {1500}, [=](auto &it, auto &d, auto now) { trek::duty_roster(it, st, d, now); }},
{"st_warp", {2000}, [=](auto &it, auto &d, auto) { trek::warp_core(it, st, d); }},
{"st_log", {14000, 6000}, [=](auto &it, auto &d, auto now) { trek::captains_log(it, st, d, now); }},
};
}
int main(int argc, char **argv) {
const auto all = pages();
if (argc >= 2 && std::string(argv[1]) == "list") {
for (const auto &p : all) {
printf("%s", p.name);
for (size_t i = 0; i < p.moments.size(); i++)
printf("%c%u", i ? ',' : ' ', (unsigned) p.moments[i]);
printf("\n");
}
return 0;
}
if (argc < 4) {
fprintf(stderr, "usage: render list | render <out_dir> <scenario> <page>:<ms> ...\n");
return 2;
}
const std::string out = argv[1];
const HomeData data = sample(argv[2]);
for (int a = 3; a < argc; a++) {
const std::string arg = argv[a];
const size_t colon = arg.find(':');
const std::string name = arg.substr(0, colon);
const uint32_t at = colon == std::string::npos ? 0 : (uint32_t) std::stoul(arg.substr(colon + 1));
const Page *page = nullptr;
for (const auto &p : all)
if (name == p.name)
page = &p;
if (page == nullptr) {
fprintf(stderr, "unknown page %s\n", name.c_str());
return 1;
}
EL512Display it;
it.shown_at = 1000000;
g_millis = it.shown_at + at;
// Like the device: start from a clear buffer, then the page draws.
it.fill(display::COLOR_OFF);
page->draw(it, data, sample_time());
const std::string path = out + "/" + name + "_" + std::to_string(at) + ".pbm";
FILE *f = fopen(path.c_str(), "wb");
if (f == nullptr) {
fprintf(stderr, "cannot write %s\n", path.c_str());
return 1;
}
fprintf(f, "P4\n%d %d\n", EL512Display::WIDTH, EL512Display::HEIGHT);
fwrite(it.fb, 1, sizeof(it.fb), f);
fclose(f);
printf("%s\n", path.c_str());
}
return 0;
}
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""Render the themed ESPHome pages to PNG on this PC, for checking layouts
without flashing and for example images.
Compiles the page code (components/el512/cyberpunk, konosuba, steins, trek)
natively with g++ against ESPHome's own display and font code (from the
installed esphome package) and the exact font bitmaps of the last
`esphome compile` (its generated main.cpp), then draws each page at given
moments after it was shown. Run `esphome compile esphome/el512-panel-local.yaml`
first, and again after changing fonts in the YAML.
python tools/render_pages.py # all pages, default moments
python tools/render_pages.py --page ks_explosion --at 0,6900,15000
python tools/render_pages.py --scenario busy # also: normal, empty, alert
python tools/render_pages.py --sheet # plus one contact sheet
python tools/render_pages.py --list # page names and moments
python tools/render_pages.py --examples # docs/images/pages/<page>.png
Output goes to .render/out (git-ignored) unless --out says otherwise. Needs a
g++ with C++20 (MinGW-w64 on Windows works) and Pillow.
"""
import argparse
import re
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
WORK = ROOT / ".render"
PAGE_SOURCES = ["cyberpunk", "konosuba", "steins", "trek"]
PAGE_HEADERS = ["el512_pages.h", "el512_helpers.h", "konosuba_sprites.h", "steins_sprites.h", "trek_sprites.h"]
ESPHOME_PARTS = ["core", "components/display", "components/font", "components/host", "components/time",
"components/lvgl", "components/graph"]
ESPHOME_SOURCES = ["components/display/display.cpp", "components/display/rect.cpp",
"components/font/font.cpp", "core/time.cpp", "core/color.cpp", "core/helpers.cpp"]
# ESPHome's headers include "defines.h" from their own folder; on the host only
# the display and font code is wanted.
HOST_DEFINES = "#pragma once\n#define USE_HOST\n#define USE_DISPLAY\n#define USE_FONT\n#define ESPHOME_LOG_LEVEL 0\n"
COMPAT = """#pragma once
// MinGW lacks strcasestr, which ESPHome's helpers use.
#ifdef __cplusplus
#include <cctype>
#include <cstring>
inline char *strcasestr(const char *h, const char *n) {
const size_t len = strlen(n);
for (; *h; h++)
if (strncasecmp(h, n, len) == 0)
return (char *) h;
return len == 0 ? (char *) h : nullptr;
}
#endif
"""
AMBER = (255, 170, 30)
BACKGROUND = (20, 12, 0)
def run(cmd, **kw):
r = subprocess.run(cmd, capture_output=True, text=True, **kw)
if r.returncode != 0:
sys.stderr.write(r.stdout + r.stderr)
raise SystemExit(f"failed: {' '.join(str(c) for c in cmd[:3])} ...")
return r.stdout
def write_if_changed(path, text):
if not path.exists() or path.read_text(encoding="utf-8") != text:
path.write_text(text, encoding="utf-8")
def stage_esphome(stage):
"""Copy the needed part of the installed ESPHome sources, with host defines."""
import esphome
src = Path(esphome.__file__).parent
marker = stage / "esphome" / ".version"
version = getattr(__import__("esphome.const", fromlist=["__version__"]), "__version__", "?")
if marker.exists() and marker.read_text() == version:
return version
shutil.rmtree(stage / "esphome", ignore_errors=True)
for part in ESPHOME_PARTS:
dst = stage / "esphome" / part
dst.mkdir(parents=True, exist_ok=True)
for f in (src / part).iterdir():
if f.suffix in (".h", ".cpp", ".c"):
shutil.copy2(f, dst / f.name)
(stage / "esphome" / "core" / "defines.h").write_text(HOST_DEFINES)
shutil.rmtree(stage / "obj", ignore_errors=True) # rebuild ESPHome objects
marker.write_text(version)
return version
def extract_fonts(main_cpp, stage):
"""Turn the fonts of ESPHome's generated main.cpp into fonts.cpp / fonts.h."""
text = main_cpp.read_text(encoding="utf-8")
cpp = ['#include "fonts.h"', "", "using esphome::font::Font;", "using esphome::font::Glyph;", ""]
hdr = ["#pragma once", '#include "esphome/components/font/font.h"', ""]
for name, glyphs, args in re.findall(r"new\((\w+)\) font::Font\((\w+), ([^;]*)\);", text):
gdef = re.search(rf"static const font::Glyph {glyphs}\[\] = (\{{.*?\}});\n", text)
if gdef is None:
raise SystemExit(f"glyphs of {name} not found in {main_cpp}")
for data in sorted(set(re.findall(r"\((uint8_t_id\w*) \+", gdef.group(1)))):
ddef = re.search(rf"static constexpr uint8_t {data}\[\] PROGMEM = (\{{.*?\}});\n", text)
cpp.append(f"static constexpr uint8_t {data}[] = {ddef.group(1)};")
cpp.append(f"static const Glyph {glyphs}[] = {gdef.group(1)};")
cpp.append(f"Font *const {name} = new Font({glyphs}, {args});")
cpp.append("")
hdr.append(f"extern esphome::font::Font *const {name};")
if len(hdr) == 3:
raise SystemExit(f"no fonts found in {main_cpp}")
write_if_changed(stage / "fonts.cpp", "\n".join(cpp) + "\n")
write_if_changed(stage / "fonts.h", "\n".join(hdr) + "\n")
def stage_pages(stage):
"""Page sources, with the host EL512Display in place of the panel's."""
pages = stage / "el512"
pages.mkdir(parents=True, exist_ok=True)
comp = ROOT / "components" / "el512"
for name in PAGE_HEADERS + [f"{p}.h" for p in PAGE_SOURCES] + [f"{p}.cpp" for p in PAGE_SOURCES]:
write_if_changed(pages / name, (comp / name).read_text(encoding="utf-8"))
here = Path(__file__).resolve().parent / "render"
write_if_changed(pages / "el512_display.h", (here / "el512_display.h").read_text(encoding="utf-8"))
for name in ("render_main.cpp", "host_stubs.cpp"):
write_if_changed(stage / name, (here / name).read_text(encoding="utf-8"))
write_if_changed(stage / "compat.h", COMPAT)
def build(stage, cxx):
obj = stage / "obj"
obj.mkdir(exist_ok=True)
flags = ["-std=gnu++20", "-O1", "-include", str(stage / "compat.h"), "-I", str(stage), "-I", str(stage / "el512")]
objects = []
# ESPHome's own sources: compiled once per ESPHome version.
for src in ESPHOME_SOURCES:
o = obj / (src.replace("/", "_") + ".o")
if not o.exists():
run([cxx, *flags, "-w", "-c", str(stage / "esphome" / src), "-o", str(o)])
objects.append(o)
# Ours: rebuilt when they change.
for src in [stage / "fonts.cpp", stage / "render_main.cpp", stage / "host_stubs.cpp"] + [stage / "el512" / f"{p}.cpp" for p in PAGE_SOURCES]:
o = obj / (src.stem + ".o")
deps = [src] + list((stage / "el512").glob("*.h")) + [stage / "fonts.h"]
if not o.exists() or any(d.stat().st_mtime > o.stat().st_mtime for d in deps):
warn = ["-w"] if src.stem == "fonts" else ["-Wall"]
out = run([cxx, *flags, *warn, "-c", str(src), "-o", str(o)])
sys.stdout.write(out)
objects.append(o)
exe = stage / ("render.exe" if sys.platform == "win32" else "render")
if not exe.exists() or any(o.stat().st_mtime > exe.stat().st_mtime for o in objects):
run([cxx, *[str(o) for o in objects], "-o", str(exe), "-static"])
return exe
def to_png(pbm, png, scale):
from PIL import Image
# PBM stores lit pixels as 1, which Pillow reads as black: invert for the mask.
lit = Image.open(pbm).convert("L").point(lambda v: 255 - v)
img = Image.new("RGB", lit.size, BACKGROUND)
img.paste(AMBER, mask=lit)
if scale != 1:
img = img.resize((img.width * scale, img.height * scale), Image.NEAREST)
img.save(png)
pbm.unlink()
def contact_sheet(pngs, path, cols=2):
from PIL import Image, ImageDraw
imgs = [Image.open(p) for p in pngs]
w, h = imgs[0].size
label = 18
sheet = Image.new("RGB", (cols * (w + 8) + 8, ((len(imgs) + cols - 1) // cols) * (h + label + 8) + 8), (0, 0, 0))
draw = ImageDraw.Draw(sheet)
for i, (p, im) in enumerate(zip(pngs, imgs)):
x, y = 8 + (i % cols) * (w + 8), 8 + (i // cols) * (h + label + 8)
draw.text((x, y), p.stem, fill=(180, 120, 20))
sheet.paste(im, (x, y + label))
sheet.save(path)
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--page", action="append", help="page name (repeatable; default: all)")
ap.add_argument("--at", help="moments in ms after the page was shown, e.g. 0,2000 (default: per page)")
ap.add_argument("--scenario", default="normal", choices=["normal", "empty", "alert", "busy"])
ap.add_argument("--out", type=Path, default=WORK / "out")
ap.add_argument("--scale", type=int, default=2)
ap.add_argument("--sheet", action="store_true", help="also write sheet.png with all renders")
ap.add_argument("--list", action="store_true", help="list pages and their default moments")
ap.add_argument("--examples", action="store_true",
help="one image per page (its first moment) as docs/images/pages/<page>.png")
ap.add_argument("--build-dir", type=Path, default=ROOT / "esphome/.esphome/build/el512-panel",
help="ESPHome build folder with src/main.cpp")
ap.add_argument("--cxx", default=shutil.which("g++") or "g++")
args = ap.parse_args()
main_cpp = args.build_dir / "src" / "main.cpp"
if not main_cpp.exists():
raise SystemExit(f"{main_cpp} not found: run `esphome compile esphome/el512-panel-local.yaml` first")
WORK.mkdir(exist_ok=True)
stage_esphome(WORK)
extract_fonts(main_cpp, WORK)
stage_pages(WORK)
exe = build(WORK, args.cxx)
listing = {}
for line in run([str(exe), "list"]).splitlines():
name, _, moments = line.partition(" ")
listing[name] = [int(m) for m in moments.split(",") if m]
if args.list:
for name, moments in listing.items():
print(f"{name:14} {', '.join(map(str, moments))}")
return
if args.examples:
args.out, args.at = ROOT / "docs/images/pages", None
listing = {n: m[:1] for n, m in listing.items()}
names = args.page or list(listing)
for n in names:
if n not in listing:
raise SystemExit(f"unknown page {n}; pages: {', '.join(listing)}")
jobs = [f"{n}:{m}" for n in names
for m in ([int(x) for x in args.at.split(",")] if args.at else listing[n])]
args.out.mkdir(parents=True, exist_ok=True)
pngs = []
for pbm in run([str(exe), str(args.out), args.scenario, *jobs]).splitlines():
pbm = Path(pbm)
stem = pbm.stem.rsplit("_", 1)[0] if args.examples else pbm.stem
png = pbm.with_name(f"{stem}{'' if args.scenario == 'normal' else '_' + args.scenario}.png")
to_png(pbm, png, args.scale)
pngs.append(png)
if args.sheet and pngs:
contact_sheet(pngs, args.out / f"sheet_{args.scenario}.png")
pngs.append(args.out / f"sheet_{args.scenario}.png")
print(f"{len(pngs)} image(s) in {args.out}")
if __name__ == "__main__":
main()