Public Access
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:
1 parent
13a499fd97
commit
d2e81a5a36
27 files changed
+627
-38
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user