diff --git a/.gitignore b/.gitignore index c246de7..4944140 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ esphome/*-local.yaml # Python bytecode (ESPHome component) __pycache__/ + +# tools/render_pages.py build folder and renders +.render/ diff --git a/README.md b/README.md index 37f562e..8cb6ea5 100644 --- a/README.md +++ b/README.md @@ -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 home_assistant/packages/ el512_panel.yaml: helper sensors for the ESPHome pages 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) docs/ datasheet and notes ``` @@ -198,6 +198,11 @@ endorsed by Valve. ## 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 [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: @@ -224,6 +229,11 @@ with or endorsed by CD PROJEKT RED. ## 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 [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: @@ -256,6 +266,11 @@ affiliated with or endorsed by them. ## 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 [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 @@ -282,6 +297,11 @@ from the series is used. Steins;Gate is © MAGES. and its other rights holders; ## 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 [components/el512/trek.cpp](components/el512/trek.cpp) with Antonio (standing in for 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 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 ```cpp diff --git a/components/el512/cyberpunk.cpp b/components/el512/cyberpunk.cpp index c934aa2..464dd5b 100644 --- a/components/el512/cyberpunk.cpp +++ b/components/el512/cyberpunk.cpp @@ -404,9 +404,10 @@ void netrunner_hud(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime cut_frame(it, 254, 150, 258, 86); tag(it, f.mono, 254, 150, 14, "ZONES"); for (int i = 0; i < 4; i++) { - const int x = 264 + (i % 2) * 124, y = 192 + (i / 2) * 34; - it.print(x, y, f.label, TextAlign::BASELINE_LEFT, el512::fit(f.label, d.rooms[i].name, 62).c_str()); - it.print(x + 116, y, f.value, TextAlign::BASELINE_RIGHT, temp_text(d.rooms[i].value).c_str()); + // Name on top, temperature below it. + const int x = 264 + (i % 2) * 124, y = 164 + (i / 2) * 36; + 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. @@ -432,7 +433,7 @@ void breach_protocol(EL512Display &it, const Fonts &f) { // Header and timer. 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); const bool low = v.remain < 10.0f && (t / 250) % 2; if (!low) @@ -441,8 +442,8 @@ void breach_protocol(EL512Display &it, const Fonts &f) { // Code matrix. cut_frame(it, 0, 40, 296, 216); - it.print(10, 54, f.mono, TextAlign::BASELINE_LEFT, "CODE MATRIX"); - draw_matrix(it, {10, 62, 46, 31, f.code}, p, v); + it.print(10, 52, f.mono, TextAlign::BASELINE_LEFT, "CODE MATRIX"); + draw_matrix(it, {10, 64, 46, 31, f.code}, p, v); // Buffer. it.print(304, 54, f.mono, TextAlign::BASELINE_LEFT, "BUFFER"); diff --git a/components/el512/el512_pages.h b/components/el512/el512_pages.h index 4462c0b..bc89996 100644 --- a/components/el512/el512_pages.h +++ b/components/el512/el512_pages.h @@ -96,6 +96,12 @@ inline std::string upper(std::string 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) { int x, y, w, h; it.get_text_bounds(0, 0, text, font, display::TextAlign::TOP_LEFT, &x, &y, &w, &h); diff --git a/components/el512/konosuba.cpp b/components/el512/konosuba.cpp index 49ccb30..c6dc6eb 100644 --- a/components/el512/konosuba.cpp +++ b/components/el512/konosuba.cpp @@ -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). if (!blasting) { 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()) 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) break; 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"}, {"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). note(it, 386, 48, 116, 94); 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(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); 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, 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()); @@ -535,8 +535,8 @@ void party_battle(EL512Display &it, const Fonts &f, const HomeData &d) { // Scenery. it.print(8, 6, f.jp, TextAlign::TOP_LEFT, "戦闘"); it.printf(56, 12, f.pixel, TextAlign::TOP_LEFT, "ROUND %u", (unsigned) (round + 1)); - arc(it, 110, GROUND + 70, 110, -52, 52); - arc(it, 300, GROUND + 90, 120, -40, 40); + // A hill between the party and the toad, clear of both. + arc(it, 270, GROUND + 50, 80, -46, 46); it.horizontal_line(0, GROUND, W); for (int i = 0; i < 18; i++) { const int x = (int) (hash(i, 21) % W); diff --git a/components/el512/steins.cpp b/components/el512/steins.cpp index 70a6116..7301cea 100644 --- a/components/el512/steins.cpp +++ b/components/el512/steins.cpp @@ -223,7 +223,7 @@ void d_mail(EL512Display &it, const Fonts &f, const HomeData &d) { for (int b = 0; b < 4; b++) it.filled_rectangle(146 + b * 6, 26 - b * 2 - 2, 4, b * 2 + 2, COLOR_OFF); 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); std::string body = c < TYPE_AT ? "" : typed(msg, c - TYPE_AT, CPS); 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.printf(MX + 209, MY + 23, f.mono, TextAlign::CENTER, "0:%02u", (unsigned) ((ZAP + 300 - c) / 1000)); } 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. 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. const Gadget &g = gadgets[sel]; const uint32_t since = t % STEP; - it.rectangle(340, 38, 170, 128); - it.rectangle(342, 40, 166, 124); - it.printf(350, 46, f.title, TextAlign::TOP_LEFT, "NO.%s", g.no.c_str()); - it.print(502, 46, f.title, TextAlign::TOP_RIGHT, g.value.c_str()); - int y = 74; + // Number, name, value, then the description. + it.rectangle(340, 38, 170, 140); + it.rectangle(342, 40, 166, 136); + it.printf(350, 44, f.title, TextAlign::TOP_LEFT, "NO.%s", g.no.c_str()); + int y = 68; for (const auto &l : wrap(it, f.mono, g.name, 150, 2)) { it.print(350, y, f.mono, TextAlign::TOP_LEFT, l.c_str()); y += 15; } - it.horizontal_line(350, y + 2, 150); - y += 8; + it.print(350, y + 2, f.title, TextAlign::TOP_LEFT, g.value.c_str()); + 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)) { it.print(350, y, f.mono, TextAlign::TOP_LEFT, l.c_str()); 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) { 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. @@ -441,14 +447,15 @@ void world_lines(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime no const int phase = (int) (t / 80); for (const auto &fl : fields) { 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.print(PX1, y - 3, f.pixel, TextAlign::BOTTOM_RIGHT, fl.name); - } else { + else 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, - fl.name); - } + const bool above = fl.div > 1.1f; + 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. @@ -472,7 +479,7 @@ void world_lines(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime no points++; } 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. if (now.is_valid() && t >= 1500) { diff --git a/components/el512/trek.cpp b/components/el512/trek.cpp index 70d3e86..9836a0e 100644 --- a/components/el512/trek.cpp +++ b/components/el512/trek.cpp @@ -173,16 +173,16 @@ void lcars_status(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime n for (int i = 0; i < 3; i++) { const int y = 48 + i * 24; 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()); } // Environmental. it.print(84, 128, f.small, TextAlign::TOP_LEFT, "ENVIRONMENTAL DECKS 1-4"); 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 + 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. @@ -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 row = 0; row < 9; row++) { 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) continue; 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++) { const int y = 36 + i * 44; 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()); - readout(it, 0, y + 22, 200, 10, rows[i].frac); + it.print(196, y, f.small, TextAlign::TOP_RIGHT, rows[i].value.c_str()); + 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, 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."; if (!std::isnan(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)) entry += " The solar collectors deliver " + watts(d.solar) + (std::isnan(d.house) ? std::string(".") : " while the replicators draw " + watts(d.house) + "."); diff --git a/docs/images/pages/cyber_breach.png b/docs/images/pages/cyber_breach.png new file mode 100644 index 0000000..d6d2966 Binary files /dev/null and b/docs/images/pages/cyber_breach.png differ diff --git a/docs/images/pages/cyber_hud.png b/docs/images/pages/cyber_hud.png new file mode 100644 index 0000000..7d968dc Binary files /dev/null and b/docs/images/pages/cyber_hud.png differ diff --git a/docs/images/pages/cyber_scan.png b/docs/images/pages/cyber_scan.png new file mode 100644 index 0000000..03d2bf8 Binary files /dev/null and b/docs/images/pages/cyber_scan.png differ diff --git a/docs/images/pages/cyber_split.png b/docs/images/pages/cyber_split.png new file mode 100644 index 0000000..f8c5df1 Binary files /dev/null and b/docs/images/pages/cyber_split.png differ diff --git a/docs/images/pages/ks_battle.png b/docs/images/pages/ks_battle.png new file mode 100644 index 0000000..17cd020 Binary files /dev/null and b/docs/images/pages/ks_battle.png differ diff --git a/docs/images/pages/ks_card.png b/docs/images/pages/ks_card.png new file mode 100644 index 0000000..2d71d4b Binary files /dev/null and b/docs/images/pages/ks_card.png differ diff --git a/docs/images/pages/ks_explosion.png b/docs/images/pages/ks_explosion.png new file mode 100644 index 0000000..ca31b22 Binary files /dev/null and b/docs/images/pages/ks_explosion.png differ diff --git a/docs/images/pages/ks_guild.png b/docs/images/pages/ks_guild.png new file mode 100644 index 0000000..ddbaf60 Binary files /dev/null and b/docs/images/pages/ks_guild.png differ diff --git a/docs/images/pages/sg_dmail.png b/docs/images/pages/sg_dmail.png new file mode 100644 index 0000000..ab22010 Binary files /dev/null and b/docs/images/pages/sg_dmail.png differ diff --git a/docs/images/pages/sg_lab.png b/docs/images/pages/sg_lab.png new file mode 100644 index 0000000..c25c317 Binary files /dev/null and b/docs/images/pages/sg_lab.png differ diff --git a/docs/images/pages/sg_meter.png b/docs/images/pages/sg_meter.png new file mode 100644 index 0000000..69b2232 Binary files /dev/null and b/docs/images/pages/sg_meter.png differ diff --git a/docs/images/pages/sg_world.png b/docs/images/pages/sg_world.png new file mode 100644 index 0000000..085bdcd Binary files /dev/null and b/docs/images/pages/sg_world.png differ diff --git a/docs/images/pages/st_lcars.png b/docs/images/pages/st_lcars.png new file mode 100644 index 0000000..fc308f5 Binary files /dev/null and b/docs/images/pages/st_lcars.png differ diff --git a/docs/images/pages/st_log.png b/docs/images/pages/st_log.png new file mode 100644 index 0000000..7fd5d13 Binary files /dev/null and b/docs/images/pages/st_log.png differ diff --git a/docs/images/pages/st_roster.png b/docs/images/pages/st_roster.png new file mode 100644 index 0000000..d9a4583 Binary files /dev/null and b/docs/images/pages/st_roster.png differ diff --git a/docs/images/pages/st_warp.png b/docs/images/pages/st_warp.png new file mode 100644 index 0000000..20a29da Binary files /dev/null and b/docs/images/pages/st_warp.png differ diff --git a/tools/render/el512_display.h b/tools/render/el512_display.h new file mode 100644 index 0000000..bef5b2f --- /dev/null +++ b/tools/render/el512_display.h @@ -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 +#include +#include + +#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 diff --git a/tools/render/host_stubs.cpp b/tools/render/host_stubs.cpp new file mode 100644 index 0000000..f5d96d1 --- /dev/null +++ b/tools/render/host_stubs.cpp @@ -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 + +#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 diff --git a/tools/render/render_main.cpp b/tools/render/render_main.cpp new file mode 100644 index 0000000..5076543 --- /dev/null +++ b/tools/render/render_main.cpp @@ -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 : ... one PBM per page and moment +// +// Scenarios: normal, empty (no data), alert (3D printer paused), busy (long +// names, high values, export). +#include +#include +#include +#include + +#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 moments; // ms after the page was shown; the first is its example + std::function draw; +}; + +static std::vector 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 : ...\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; +} diff --git a/tools/render_pages.py b/tools/render_pages.py new file mode 100644 index 0000000..4e344dc --- /dev/null +++ b/tools/render_pages.py @@ -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/.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 +#include +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/.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()