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

+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;
}