Add Interstellar and Your Name pages

Interstellar: Gargantua with a time dilation clock (Earth today vs.
Miller's planet), TARS with his settings and remarks about the house,
the Endurance spinning at a solar-driven RPM while a Ranger docks, and
the tesseract's bookshelf with the time in binary dust and the Message's
first word in Morse on a watch.

Your Name: comet Tiamat splitting over Itomori's lake, and
kataware-doki, a countdown to sunset from sun.sun where two silhouettes
stand on the crater rim during the twilight hour.

HomeData gains sunset, sunrise and sun elevation (with an ISO time
parser); wrap() and typed() move to el512_pages.h. Share Tech Mono 22
gets the full Latin set. All pages are in the renderer, checked with
normal, busy and empty data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-19 18:59:00 +02:00
1 parent a4ce97d744
commit f46f7ab6c5
22 files changed
+1159 -110

No files matched your search

+56 -5
View File
@@ -86,6 +86,8 @@ components/el512/ shared driver + ESPHome component (one copy of the code)
konosuba.* animated KonoSuba pages; konosuba_sprites.h from tools/
steins.* animated Steins;Gate pages; steins_sprites.h from tools/
trek.* animated Star Trek / Lower Decks pages; trek_sprites.h from tools/
interstellar.* animated Interstellar pages
yourname.* animated Your Name pages; yourname_sprites.h from tools/
el512_pages.h home data and helpers shared by the themed pages
el512_display.* ESPHome display platform `el512`
__init__.py, display.py ESPHome config schema
@@ -95,7 +97,8 @@ 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, render_pages.py (+ render/: PC renderer)
trek_sprites.py, yourname_sprites.py, render_pages.py
(+ render/: PC renderer)
test/ unit tests (placeholder)
docs/ datasheet and notes
```
@@ -114,11 +117,12 @@ pio device monitor
## ESPHome / Home Assistant
[esphome/el512-panel.yaml](esphome/el512-panel.yaml) turns the panel into a Home
Assistant display with twenty-four pages: clock and weather, forecast, agenda, a
sensor dashboard with a message line, now playing, Plex, solar, Still Alive, and
sixteen animated themed pages ([Cyberpunk](#cyberpunk-pages),
Assistant display with thirty pages: clock and weather, forecast, agenda, a sensor
dashboard with a message line, now playing, Plex, solar, Still Alive, and
twenty-two animated themed pages ([Cyberpunk](#cyberpunk-pages),
[KonoSuba](#konosuba-pages), [Steins;Gate](#steinsgate-pages),
[Star Trek](#star-trek-pages)). Pages
[Star Trek](#star-trek-pages), [Interstellar](#interstellar-pages),
[Your Name](#your-name-pages)). Pages
rotate on an interval, in a random order that is reshuffled every hour; Home Assistant gets a page select, a pause switch, the
interval, a message text, a "Play Still Alive" button, and Display / Low power mode
switches (ENABLE, _LOWPWR).
@@ -329,6 +333,53 @@ 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.
## Interstellar pages
| | |
|---|---|
| ![Gargantua](docs/images/pages/is_gargantua.png) | ![TARS](docs/images/pages/is_tars.png) |
| ![Endurance](docs/images/pages/is_endurance.png) | ![Tesseract](docs/images/pages/is_tesseract.png) |
Four animated pages drawn by
[components/el512/interstellar.cpp](components/el512/interstellar.cpp) with Michroma
and Share Tech Mono, all in the rotation:
- **Gargantua**: the black hole with its lensed accretion disk turning, and time
dilation: today's Earth time next to today on Miller's planet (one hour there is seven
years here), with a bar filling towards the next Miller second, 17 Earth hours away.
- **TARS**: the robot's four slabs stepping in turn, his settings (humor, honesty,
discretion, trust) next to the home's readings, and dry remarks about the house.
- **Endurance**: the ring spins at an RPM set by solar power while a Ranger flies in,
matches the spin and docks in the hub.
- **Tesseract**: the bookshelf from behind with a book falling out, the time as
binary bars of dust, and the first word of the Message (or STAY) in Morse code on a
watch's second hand.
These pages are fan art: drawings and texts are original. Interstellar is © Paramount
Pictures and Warner Bros.; this project is not affiliated with or endorsed by them.
## Your Name pages
| | |
|---|---|
| ![Comet Tiamat](docs/images/pages/yn_comet.png) | ![Kataware-doki](docs/images/pages/yn_twilight.png) |
Two animated pages drawn by [components/el512/yourname.cpp](components/el512/yourname.cpp)
with Yuji Syuku, Caveat and Cormorant Garamond, both in the rotation:
- **Comet Tiamat**: the comet crosses the night sky over Itomori's round lake and
splits in two, shedding sparks; the shrine gate on the hill, the town's lights, the
clock and the weather.
- **Kataware-doki**: a countdown to sunset (at night, to sunrise) from Home Assistant's
`sun.sun`. In the twilight hour, from 45 minutes before sunset until civil dusk,
the sky glows, the sun sinks behind the crater rim and two silhouettes stand on it.
The pixel art lives in [tools/yourname_sprites.py](tools/yourname_sprites.py).
These pages are fan art: the pixel art, drawings and texts are original. Your Name
(Kimi no Na wa) is by Makoto Shinkai, © its rights holders; 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
+2
View File
@@ -4,9 +4,11 @@
#include "cyberpunk.h"
#include "el512_helpers.h"
#include "interstellar.h"
#include "konosuba.h"
#include "steins.h"
#include "trek.h"
#include "yourname.h"
#include "el512_panel.h"
#include "esphome/components/display/display_buffer.h"
#include "esphome/core/component.h"
+66
View File
@@ -9,7 +9,9 @@
#include <cstdint>
#include <cstdio>
#include <string>
#include <vector>
#include "el512_helpers.h"
#include "esphome/components/display/display.h"
#include "esphome/core/time.h"
@@ -34,6 +36,9 @@ struct HomeData {
std::string agenda; // sensor.el512_agenda events: "day|time|title;..."
std::string message; // the panel's Message text
std::string printer; // 3D printer state (Moonraker: printing, paused, error, ...)
int64_t sunset{0}; // next sunset, Unix time (sun.sun next_setting); 0 = unknown
int64_t sunrise{0}; // next sunrise, Unix time (sun.sun next_rising); 0 = unknown
float sun_elevation{NAN}; // degrees above the horizon
Reading rooms[4];
// Today's solar power in 15-minute slots (highest value seen per slot; -1 =
@@ -96,6 +101,30 @@ inline std::string upper(std::string s) {
return s;
}
// Days since 1970-01-01 for a civil date (Howard Hinnant's algorithm).
inline int64_t days_from_civil(int y, int m, int d) {
y -= m <= 2;
const int era = (y >= 0 ? y : y - 399) / 400;
const int yoe = y - era * 400;
const int doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
const int doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
return (int64_t) era * 146097 + doe - 719468;
}
// "2026-10-19T16:32:10.123+00:00" (as Home Assistant sends datetimes) to Unix
// time; 0 if it doesn't parse.
inline int64_t parse_iso_utc(const std::string &s) {
int y, mo, d, h, mi, se;
if (sscanf(s.c_str(), "%d-%d-%dT%d:%d:%d", &y, &mo, &d, &h, &mi, &se) != 6)
return 0;
int64_t t = days_from_civil(y, mo, d) * 86400 + h * 3600 + mi * 60 + se;
const size_t p = s.find_first_of("+-", 19);
int oh, om;
if (p != std::string::npos && sscanf(s.c_str() + p + 1, "%d:%d", &oh, &om) == 2)
t -= (s[p] == '+' ? 1 : -1) * (int64_t) (oh * 3600 + om * 60);
return t;
}
inline std::string lower(std::string s) {
for (auto &c : s)
c = (char) tolower((unsigned char) c);
@@ -108,6 +137,43 @@ inline int text_width(display::Display &it, display::BaseFont *font, const char
return w;
}
// Greedy word wrap into at most max_lines lines of `width` pixels; the last
// line ends in "..." when text is left over.
inline std::vector<std::string> wrap(display::Display &it, display::BaseFont *font, const std::string &text, int width,
size_t max_lines) {
std::vector<std::string> lines;
std::string line;
size_t i = 0;
while (i <= text.size()) {
size_t j = text.find(' ', i);
if (j == std::string::npos)
j = text.size();
const std::string word = text.substr(i, j - i);
const std::string joined = line.empty() ? word : line + " " + word;
if (!line.empty() && text_width(it, font, joined.c_str()) > width) {
lines.push_back(line);
line = word;
} else {
line = joined;
}
i = j + 1;
}
if (!line.empty())
lines.push_back(line);
if (lines.size() > max_lines) {
lines.resize(max_lines);
lines.back() += "...";
}
for (auto &l : lines)
l = fit(font, l, width);
return lines;
}
// The part of `s` typed so far at `cps` characters per second.
inline std::string typed(const std::string &s, uint32_t t, int cps) {
return s.substr(0, std::min<size_t>(s.size(), (size_t) t * cps / 1000));
}
// Draw a sprite (optionally only its first `rows` rows), scaled up in blocks.
inline void sprite(display::Display &it, const Sprite &s, int x, int y, int scale = 1, int rows = -1,
Color color = display::COLOR_ON) {
+481
View File
@@ -0,0 +1,481 @@
#include "interstellar.h"
#include <algorithm>
#include <cmath>
#include <cstring>
#include <vector>
#include "el512_display.h"
#include "esphome/core/hal.h"
namespace esphome::el512::interstellar {
using display::COLOR_OFF;
using display::COLOR_ON;
using display::Display;
using display::TextAlign;
namespace {
using namespace fx;
constexpr int W = 512, H = 256;
const uint8_t BAYER[4][4] = {{0, 8, 2, 10}, {12, 4, 14, 6}, {3, 11, 1, 9}, {15, 7, 13, 5}};
bool dither(int x, int y, int level) { return BAYER[y & 3][x & 3] < level; }
std::string kw(float w) { return std::isnan(w) ? "--" : watts(w); }
// Faint, slowly twinkling stars; skip(x, y) keeps them off busy areas.
template<typename Skip> void stars(EL512Display &it, uint32_t t, int count, uint32_t seed, Skip skip) {
for (int i = 0; i < count; i++) {
const uint32_t r = hash(i, seed);
const int x = (int) (r % W), y = (int) ((r >> 9) % H);
if (skip(x, y) || (t / 230 + (r >> 20)) % 17 == 0)
continue;
it.draw_pixel_at(x, y, COLOR_ON);
}
}
// Segmented bar, lit up to frac.
void seg_bar(Display &it, int x, int y, int w, int h, float frac) {
const int n = w / 5, lit = std::isnan(frac) ? 0 : (int) std::lround(std::clamp(frac, 0.0f, 1.0f) * n);
for (int i = 0; i < n; i++) {
if (i < lit)
it.filled_rectangle(x + i * 5, y, 4, h);
else
it.horizontal_line(x + i * 5, y + h - 1, 4);
}
}
// Seconds on Earth per second on Miller's planet: one hour there is seven
// years here.
constexpr float DILATION = 7 * 365.25f * 24;
// International Morse code, A-Z then 0-9.
const char *const MORSE[] = {".-", "-...", "-.-.", "-..", ".", "..-.", "--.", "....", "..",
".---", "-.-", ".-..", "--", "-.", "---", ".--.", "--.-", ".-.",
"...", "-", "..-", "...-", ".--", "-..-", "-.--", "--..", "-----",
".----", "..---", "...--", "....-", ".....", "-....", "--...", "---..", "----."};
const char *morse(char c) {
if (c >= 'A' && c <= 'Z')
return MORSE[c - 'A'];
if (c >= '0' && c <= '9')
return MORSE[26 + c - '0'];
return nullptr; // a space, or something Morse can't send
}
} // namespace
// --- 1. Gargantua and time dilation ---------------------------------------------------
void gargantua(EL512Display &it, const Fonts &f, ESPTime now) {
if (f.large == nullptr)
return;
const uint32_t t = it.page_age_ms();
constexpr int CX = 168, CY = 124, SHADOW = 40, DISK = 160;
stars(it, t, 90, 42, [](int x, int y) {
const int dx = x - CX, dy = y - CY;
return x > 320 || dx * dx + dy * dy < 80 * 80 || (std::abs(dy) < 12 && std::abs(dx) < DISK);
});
const float spin = t * 0.0025f;
// The far side of the disk, bent over the top and under the bottom of the
// shadow by its gravity: a thick halo above, a thinner one below. Streaks
// run around it; the approaching (left) side is brighter.
for (int y = CY - 72; y <= CY + 58; y++) {
for (int x = CX - 72; x <= CX + 72; x++) {
const int dx = x - CX, dy = y - CY, r2 = dx * dx + dy * dy;
const int outer = dy < 0 ? 70 : 56;
if (r2 < (SHADOW + 5) * (SHADOW + 5) || r2 > outer * outer)
continue;
const float r = std::sqrt((float) r2), a = std::atan2((float) dy, (float) dx);
const int level = 8 + (int) (5 * std::sin(a * 7 + spin * 3 + r * 0.35f)) + (dx < 0 ? 3 : -1) -
(int) ((r - SHADOW) / 5);
if (dither(x, y, level))
it.draw_pixel_at(x, y, COLOR_ON);
}
}
// Photon ring around the shadow.
it.circle(CX, CY, SHADOW + 2);
it.circle(CX, CY, SHADOW + 3);
// The near side of the disk, edge-on across the front of the shadow.
for (int x = CX - DISK; x <= CX + DISK; x++) {
const int dx = x - CX;
const float k = 1.0f - (float) (dx * dx) / (DISK * DISK);
const int half = (int) (1 + 6 * k);
for (int y = CY - half; y <= CY + half; y++) {
const int level = 9 + (int) (5 * std::sin(dx * 0.12f - spin * 9)) + (dx < 0 ? 4 : 0) - 2 * std::abs(y - CY);
it.draw_pixel_at(x, y, dither(x, y, level) ? COLOR_ON : COLOR_OFF);
}
}
// Time dilation.
constexpr int X = 336;
it.print(X, 6, f.title, TextAlign::TOP_LEFT, "GARGANTUA");
it.print(X, 26, f.mono, TextAlign::TOP_LEFT, "1 HOUR = 7 EARTH YEARS");
it.horizontal_line(X, 46, W - X - 4);
const float today = now.is_valid() ? now.hour * 3600.0f + now.minute * 60 + now.second : NAN;
it.print(X, 54, f.mono, TextAlign::TOP_LEFT, "EARTH, TODAY");
if (now.is_valid()) {
it.strftime(X, 70, f.large, TextAlign::TOP_LEFT, "%H:%M", now);
it.printf(506, 74, f.code, TextAlign::TOP_RIGHT, ":%02d", now.second);
}
it.print(X, 116, f.mono, TextAlign::TOP_LEFT, "MILLER'S PLANET, TODAY");
if (!std::isnan(today)) {
// One Miller second takes 17 Earth hours: three decimals, a bar for the rest.
const float miller = today / DILATION;
it.printf(X, 132, f.large, TextAlign::TOP_LEFT, "%.3f", miller);
it.print(506, 136, f.code, TextAlign::TOP_RIGHT, "S");
it.print(X, 178, f.mono, TextAlign::TOP_LEFT, "NEXT MILLER SECOND");
seg_bar(it, X, 196, 170, 8, miller - std::floor(miller));
const float left = (1.0f - (miller - std::floor(miller))) * DILATION / 3600;
it.printf(X, 212, f.mono, TextAlign::TOP_LEFT, "IN %.1f EARTH HOURS", left);
}
}
// --- 2. TARS and its settings -----------------------------------------------------------
void tars(EL512Display &it, const Fonts &f, const HomeData &d) {
if (f.mono == nullptr)
return;
const uint32_t t = it.page_age_ms();
constexpr uint32_t QUIP = 7000;
const int quip = (int) ((t / QUIP) % 6);
const uint32_t q = t % QUIP;
// TARS: four slabs; the middle pair and the outer pair step in turn.
constexpr int BX = 22, BY = 30, SW = 26, GAP = 3, BH = 192;
const int lift = ((t / 450) % 2) ? 4 : -4;
int screen_y = 0;
for (int s = 0; s < 4; s++) {
const int x = BX + s * (SW + GAP), y = BY + ((s == 1 || s == 2) ? lift : -lift);
it.rectangle(x, y, SW, BH);
for (int g = 1; g < 4; g++)
it.horizontal_line(x + 3, y + g * BH / 4, SW - 6);
it.vertical_line(x + SW / 2, y + BH - 34, 26);
if (s == 1)
screen_y = y + 16;
}
// His screen, with a waveform while he talks.
const int sx = BX + SW + GAP + 3, sw = 2 * SW + GAP - 6;
it.filled_rectangle(sx, screen_y, sw, 30);
const bool talking = q < 4000;
for (int x = 0; x < sw - 4; x++) {
const float amp = talking ? 9 * std::fabs(std::sin(x * 0.21f + t * 0.013f) * std::sin(x * 0.05f + t * 0.004f)) : 0.6f;
const int yy = screen_y + 15 + (int) (amp * std::sin(x * 0.7f + t * 0.02f));
it.draw_pixel_at(sx + 2 + x, yy, COLOR_OFF);
}
// Settings and home readings.
constexpr int X = 150;
it.print(X, 2, f.large, TextAlign::TOP_LEFT, "TARS");
it.print(X + text_width(it, f.large, "TARS") + 12, 14, f.title, TextAlign::TOP_LEFT, "SETTINGS");
const int humor = quip == 5 && q > 2600 ? 60 : 75;
struct Row {
const char *name;
float frac;
std::string value;
};
const Row settings[] = {{"HUMOR", humor / 100.0f, str("%d%%", humor)},
{"HONESTY", 0.9f, "90%"},
{"DISCRETION", 0.8f, "80%"},
{"TRUST", 1.0f, "100%"}};
const float tf = std::isnan(d.temp) ? NAN : (d.temp + 10) / 45;
const Row readings[] = {{"SOLAR", d.solar / 5000, kw(d.solar)},
{"HOUSE", d.house / 5000, kw(d.house)},
{"GRID", std::fabs(d.grid) / 5000, kw(std::fabs(d.grid))},
{"OUTSIDE", tf, std::isnan(d.temp) ? "--" : str("%.1f C", d.temp)}};
for (int i = 0; i < 4; i++) {
const int y = 48 + i * 27;
for (int col = 0; col < 2; col++) {
const Row &r = col == 0 ? settings[i] : readings[i];
const int x = X + col * 182;
it.print(x, y, f.mono, TextAlign::TOP_LEFT, r.name);
it.print(x + 172, y, f.mono, TextAlign::TOP_RIGHT, r.value.c_str());
seg_bar(it, x, y + 16, 170, 5, r.frac);
}
}
// What TARS has to say about the house.
std::string say;
switch (quip) {
case 0:
say = std::isnan(d.temp) ? "Outside temperature unknown. I'd bring a jacket anyway."
: str("Outside it's %.0f degrees. Humor setting 75%%: ", d.temp) +
(d.temp < 15 ? "bring a jacket." : "leave the jacket.");
break;
case 1:
say = "Solar is delivering " + kw(d.solar) + ". Not bad for a star 150 million kilometers away.";
break;
case 2:
say = "House load " + kw(d.house) + ". Honesty setting 90%: it's probably the dishwasher.";
break;
case 3:
say = std::isnan(d.grid) ? "Grid status unknown. Discretion setting says I shouldn't guess."
: d.grid < 0 ? "We're sending " + kw(-d.grid) + " back to the grid. Very generous of us."
: "We're drawing " + kw(d.grid) + " from the grid. Discretion setting: no comment.";
break;
case 4: {
const auto events = split(d.agenda, ';');
say = events.empty() ? "No missions on the plan. Initiating relaxation protocol."
: "Next on the mission plan: " + field(events[0], 2) + ".";
break;
}
default:
say = "That was a joke. Lowering humor setting to 60%.";
}
it.rectangle(X, 160, W - X - 2, 92);
int y = 168;
for (const auto &l : wrap(it, f.mono, typed(say, q, 32), W - X - 20, 5)) {
it.print(X + 8, y, f.mono, TextAlign::TOP_LEFT, l.c_str());
y += 16;
}
}
// --- 3. The Endurance spins while a Ranger docks ---------------------------------------
void endurance(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now) {
if (f.large == nullptr)
return;
const uint32_t t = it.page_age_ms();
constexpr int CX = 150, CY = 128, RING = 86;
constexpr uint32_t CYCLE = 16000, MATCH = 6000, DOCK = 10000;
const uint32_t c = t % CYCLE;
const float solar = std::isnan(d.solar) ? 0 : std::max(0.0f, d.solar);
const float rpm = 20 + std::min(solar, 5000.0f) / 5000 * 50;
// Degrees per ms, shown at a tenth of the speed, or it would be a blur.
const float rate = rpm * 6 / 10 / 1000;
const float rot = rate * t;
stars(it, t, 80, 7, [](int x, int y) { return (x - CX) * (x - CX) + (y - CY) * (y - CY) < 100 * 100 || x > 286; });
// The ring: twelve modules around a hub on four spokes.
it.circle(CX, CY, RING);
for (int k = 0; k < 4; k++) {
int x0, y0, x1, y1;
polar(CX, CY, 18, rot + 45 + k * 90, &x0, &y0);
polar(CX, CY, RING - 8, rot + 45 + k * 90, &x1, &y1);
it.line(x0, y0, x1, y1);
}
for (int i = 0; i < 12; i++) {
const float a = rot + i * 30;
const int half = i % 2 ? 5 : 8;
int mx, my;
polar(CX, CY, RING, a, &mx, &my);
int px[4], py[4];
for (int k = 0; k < 4; k++)
polar(mx, my, half * 1.41f, a + 45 + k * 90, &px[k], &py[k]);
it.filled_triangle(px[0], py[0], px[1], py[1], px[2], py[2]);
it.filled_triangle(px[0], py[0], px[2], py[2], px[3], py[3]);
}
it.filled_circle(CX, CY, 18, COLOR_OFF);
it.circle(CX, CY, 18);
it.circle(CX, CY, 9);
// The Ranger: flies in, spins up to match, then docks in the hub.
float rx, ry = CY, ang, match;
const char *status;
if (c < MATCH) {
// In from the top right of the starfield, nose at the hub.
const float k = (float) c / MATCH, e = 1 - (1 - k) * (1 - k);
rx = 258 + (CX + 40 - 258) * e;
ry = 20 + (CY - 20) * e;
ang = std::atan2(CX - rx, -(CY - ry)) * 180 / 3.14159f;
match = 0;
status = "APPROACH";
} else if (c < DOCK) {
// Spin rate ramps up to the ring's; the angle is its integral.
const float e = (float) (c - MATCH), k = e / (DOCK - MATCH);
rx = CX + 40 - 40 * k;
match = k;
ang = 270 + rate * e * e / (2 * (DOCK - MATCH));
status = "MATCHING SPIN";
} else {
rx = CX;
match = 1;
ang = 270 + rate * (DOCK - MATCH) / 2 + rate * (c - DOCK);
status = "DOCKED";
}
int nx, ny, lx, ly, bx, by, tx, ty;
polar((int) rx, (int) ry, 16, ang, &nx, &ny); // nose
polar((int) rx, (int) ry, 12, ang + 140, &lx, &ly); // wing
polar((int) rx, (int) ry, 12, ang - 140, &bx, &by); // wing
polar((int) rx, (int) ry, 5, ang + 180, &tx, &ty); // tail notch
it.filled_triangle(nx, ny, lx, ly, tx, ty);
it.filled_triangle(nx, ny, tx, ty, bx, by);
// Readouts.
constexpr int X = 300;
it.print(X, 6, f.title, TextAlign::TOP_LEFT, "ENDURANCE");
it.horizontal_line(X, 26, W - X - 4);
it.print(X, 34, f.mono, TextAlign::TOP_LEFT, "RING SPEED");
it.printf(X, 50, f.large, TextAlign::TOP_LEFT, "%.0f", rpm);
it.print(X + 70, 62, f.mono, TextAlign::TOP_LEFT, "RPM");
it.print(X, 96, f.mono, TextAlign::TOP_LEFT, "MATCH SPIN");
it.printf(506, 96, f.mono, TextAlign::TOP_RIGHT, "%.0f%%", match * 100);
seg_bar(it, X, 114, 205, 8, match);
if (c < MATCH)
it.printf(X, 132, f.mono, TextAlign::TOP_LEFT, "DISTANCE %.0f M", (rx - CX - 18) * 3);
if (c < DOCK || (t / 400) % 2) // DOCKED blinks
it.print(X, 150, f.code, TextAlign::TOP_LEFT, status);
it.horizontal_line(X, 186, W - X - 4);
it.printf(X, 194, f.mono, TextAlign::TOP_LEFT, "POWER (SOLAR) %s", kw(d.solar).c_str());
if (now.is_valid())
it.printf(X, 214, f.mono, TextAlign::TOP_LEFT, "MISSION DAY %d %02d:%02d", now.day_of_year, now.hour, now.minute);
}
// --- 4. The tesseract: books, the time in dust, a message in Morse -----------------------
void tesseract(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now) {
if (f.mono == nullptr)
return;
const uint32_t t = it.page_age_ms();
// The bookshelf, seen from behind; now and then a book falls out.
it.print(4, 2, f.title, TextAlign::TOP_LEFT, "TESSERACT");
it.rectangle(4, 22, 190, 226);
const int shelf_y[3] = {96, 170, 246};
const uint32_t cycle = t / 6000, p = t % 6000;
int index = 0;
const int falling = 3 + (int) (hash(cycle, 5) % 8);
for (int s = 0; s < 3; s++) {
if (s < 2)
it.horizontal_line(4, shelf_y[s], 190);
int x = 8;
for (int b = 0; x < 186; b++, index++) {
const uint32_t r = hash(s * 37 + b, 11);
const int w = std::min(186 - x, 5 + (int) (r % 9)), h = 40 + (int) ((r >> 8) % 26);
const int y = shelf_y[s] - h;
if (s == 1 && b == falling) {
if (p < 500) {
it.rectangle(x + ((p / 60) % 2), y, w - 1, h); // it shakes
} else if (p < 1300) {
const int drop = (int) ((p - 500) * (p - 500) / 8000);
if (y + drop < 244)
it.filled_rectangle(x, y + drop, w - 1, std::min(h, 244 - y - drop));
} else if (p > 4500) {
it.rectangle(x, y, w - 1, h); // back on the shelf
}
} else if (r % 4 == 0) {
it.filled_rectangle(x, y, w - 1, h);
} else {
it.rectangle(x, y, w - 1, h);
}
x += w;
}
}
// The time in dust: HH:MM as four binary digits, thick bars for 1.
constexpr int DX = 212, DTOP = 40, DBOT = 224;
it.print(DX, 4, f.mono, TextAlign::TOP_LEFT, "THE TIME, IN DUST");
int digits[4] = {0, 0, 0, 0};
if (now.is_valid()) {
digits[0] = now.hour / 10;
digits[1] = now.hour % 10;
digits[2] = now.minute / 10;
digits[3] = now.minute % 10;
}
std::string code;
for (int i = 0; i < 16; i++) {
const bool one = digits[i / 4] & (8 >> (i % 4));
code += one ? '1' : '0';
if (i % 4 == 3 && i < 15)
code += i == 7 ? " : " : " ";
const int x = DX + i * 10 + (i / 4) * 6;
const int bw = one ? 8 : 2, bx = x + (one ? 0 : 3);
for (int y = DTOP + 60; y < DBOT; y++)
for (int xx = bx; xx < bx + bw; xx++)
if (dither(xx, y, one ? 11 : 9))
it.draw_pixel_at(xx, y, COLOR_ON);
// Grains drifting down into the bar.
for (int k = 0; k < 4; k++) {
const uint32_t r = hash(i * 7 + k, 3);
const int gy = DTOP + (int) ((t / 9 + r) % 70);
it.draw_pixel_at(bx + (int) (r >> 12) % bw, gy, COLOR_ON);
}
}
it.print(DX, 232, f.mono, TextAlign::TOP_LEFT, code.c_str());
// A wristwatch ticking the message out in Morse.
// The message's first word (up to 6 letters, to fit beside the watch).
std::string word = upper(d.message.empty() ? "STAY" : d.message);
word = word.substr(0, std::min(word.find(' '), (size_t) 6));
if (word.empty())
word = "STAY";
// Timeline: dot 200 ms, dash 600, 200 between symbols, 600 between letters,
// 1400 between words, 2000 before it starts over.
struct Mark {
uint32_t at, len;
int letter, symbol;
};
std::vector<Mark> marks;
uint32_t at = 0;
for (int l = 0; l < (int) word.size(); l++) {
const char *m = morse(word[l]);
if (m == nullptr) {
at += 1400;
continue;
}
for (int s = 0; m[s]; s++) {
const uint32_t len = m[s] == '.' ? 200 : 600;
marks.push_back({at, len, l, s});
at += len + 200;
}
at += 400;
}
const uint32_t total = at + 2000, mt = t % std::max<uint32_t>(total, 1);
const Mark *cur = nullptr;
int letter = -1;
for (const auto &m : marks) {
if (mt >= m.at)
letter = m.letter;
if (mt >= m.at && mt < m.at + m.len)
cur = &m;
}
constexpr int WX = 456, WY = 84, WR = 40;
it.circle(WX, WY, WR);
it.circle(WX, WY, WR - 3);
for (int k = 0; k < 12; k++) {
int x0, y0, x1, y1;
polar(WX, WY, WR - 6, k * 30, &x0, &y0);
polar(WX, WY, WR - (k % 3 ? 9 : 13), k * 30, &x1, &y1);
it.line(x0, y0, x1, y1);
}
const int sec = now.is_valid() ? now.second : 0;
int hx, hy;
polar(WX, WY, WR - 7, sec * 6 + (cur != nullptr ? 12 : 0), &hx, &hy); // the hand twitches on each mark
it.line(WX, WY, hx, hy);
it.filled_circle(WX, WY, 3);
it.filled_rectangle(WX - 8, WY - WR - 10, 16, 8);
it.filled_rectangle(WX - 8, WY + WR + 2, 16, 8);
// The word, the letter being sent, and its dots and dashes.
// The word in one go; the letter being sent is inverted, its cell measured
// from the widths of the word's prefixes so it lines up with the glyphs.
const int x0 = WX - text_width(it, f.code, word.c_str()) / 2;
it.print(x0, 158, f.code, TextAlign::CENTER_LEFT, word.c_str());
if (letter >= 0) {
const int a = text_width(it, f.code, word.substr(0, letter).c_str());
const int b = text_width(it, f.code, word.substr(0, letter + 1).c_str());
it.invert_rect(x0 + a - 1, 146, b - a + 2, 24);
}
if (letter >= 0) {
const char *m = morse(word[letter]);
const int n = (int) strlen(m);
int mx = WX - (n * 18) / 2;
for (int s = 0; s < n; s++) {
const bool on = cur != nullptr && cur->letter == letter && cur->symbol == s;
if (m[s] == '.') {
on ? it.filled_circle(mx + 5, 190, 4) : it.circle(mx + 5, 190, 4);
} else {
on ? it.filled_rectangle(mx, 187, 14, 7) : it.rectangle(mx, 187, 14, 7);
}
mx += 18;
}
}
it.print(WX, 210, f.mono, TextAlign::TOP_CENTER, "MORSE, VIA THE");
it.print(WX, 226, f.mono, TextAlign::TOP_CENTER, "SECOND HAND");
}
} // namespace esphome::el512::interstellar
+33
View File
@@ -0,0 +1,33 @@
// Interstellar-themed animated pages for the ESPHome display: Gargantua with
// a time dilation clock, TARS and its settings, the Endurance spinning while a
// Ranger docks, and the tesseract's bookshelf with the time in dust and a
// message in Morse. Fan art: layouts, drawings and texts are original.
//
// Each function draws a whole page. Call it from a page lambda and list that
// page under animated_pages; the animation clock is the display's page_age_ms().
#pragma once
#include "el512_pages.h"
#include "esphome/components/display/display.h"
#include "esphome/core/time.h"
namespace esphome::el512 {
class EL512Display;
namespace interstellar {
struct Fonts {
display::BaseFont *title{nullptr}; // wide headers (Michroma 12)
display::BaseFont *large{nullptr}; // wide numbers (Michroma 28)
display::BaseFont *mono{nullptr}; // readouts and TARS (Share Tech Mono 14)
display::BaseFont *code{nullptr}; // larger monospace (Share Tech Mono 22)
};
void gargantua(EL512Display &it, const Fonts &f, ESPTime now);
void tars(EL512Display &it, const Fonts &f, const HomeData &d);
void endurance(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now);
void tesseract(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now);
} // namespace interstellar
} // namespace esphome::el512
-34
View File
@@ -99,40 +99,6 @@ void bar(Display &it, int x, int y, int w, int h, float frac) {
it.filled_rectangle(x + 2, y + 2, (int) ((w - 4) * std::clamp(frac, 0.0f, 1.0f)), h - 4);
}
// Greedy word wrap into at most max_lines lines of `width` pixels.
std::vector<std::string> wrap(Display &it, display::BaseFont *font, const std::string &text, int width,
size_t max_lines) {
std::vector<std::string> lines;
std::string line;
size_t i = 0;
while (i <= text.size()) {
size_t j = text.find(' ', i);
if (j == std::string::npos)
j = text.size();
const std::string word = text.substr(i, j - i);
const std::string joined = line.empty() ? word : line + " " + word;
if (!line.empty() && text_width(it, font, joined.c_str()) > width) {
lines.push_back(line);
line = word;
} else {
line = joined;
}
i = j + 1;
}
if (!line.empty())
lines.push_back(line);
if (lines.size() > max_lines) {
lines.resize(max_lines);
lines.back() += "...";
}
for (auto &l : lines)
l = fit(font, l, width);
return lines;
}
// The first part of `s` that has been typed at `cps` characters per second.
std::string typed(const std::string &s, uint32_t t, int cps) { return s.substr(0, std::min<size_t>(s.size(), t * cps / 1000)); }
// Dissolve the page in over its first 400 ms.
void intro(EL512Display &it, uint32_t t) {
if (t < 400)
-32
View File
@@ -51,38 +51,6 @@ void ripple(EL512Display &it, uint32_t age, uint32_t dur, float amp) {
it.shift_rows(y, 1, (int) std::lround(a * std::sin(y * 0.11f + age * 0.025f)));
}
std::vector<std::string> wrap(Display &it, display::BaseFont *font, const std::string &text, int width,
size_t max_lines) {
std::vector<std::string> lines;
std::string line;
size_t i = 0;
while (i <= text.size()) {
size_t j = text.find(' ', i);
if (j == std::string::npos)
j = text.size();
const std::string word = text.substr(i, j - i);
const std::string joined = line.empty() ? word : line + " " + word;
if (!line.empty() && text_width(it, font, joined.c_str()) > width) {
lines.push_back(line);
line = word;
} else {
line = joined;
}
i = j + 1;
}
if (!line.empty())
lines.push_back(line);
if (lines.size() > max_lines) {
lines.resize(max_lines);
lines.back() += "...";
}
for (auto &l : lines)
l = fit(font, l, width);
return lines;
}
std::string typed(const std::string &s, uint32_t t, int cps) { return s.substr(0, std::min<size_t>(s.size(), t * cps / 1000)); }
std::string watts_text(float w) { return std::isnan(w) ? "--" : watts(w); }
std::string temp_text(float v) { return std::isnan(v) ? "--" : str("%.1f C", v); }
-32
View File
@@ -68,38 +68,6 @@ void readout(Display &it, int x, int y, int w, int h, float frac) {
pill(it, x, y, std::max(h, (int) (w * std::clamp(frac, 0.0f, 1.0f))), h);
}
std::vector<std::string> wrap(Display &it, display::BaseFont *font, const std::string &text, int width,
size_t max_lines) {
std::vector<std::string> lines;
std::string line;
size_t i = 0;
while (i <= text.size()) {
size_t j = text.find(' ', i);
if (j == std::string::npos)
j = text.size();
const std::string word = text.substr(i, j - i);
const std::string joined = line.empty() ? word : line + " " + word;
if (!line.empty() && text_width(it, font, joined.c_str()) > width) {
lines.push_back(line);
line = word;
} else {
line = joined;
}
i = j + 1;
}
if (!line.empty())
lines.push_back(line);
if (lines.size() > max_lines) {
lines.resize(max_lines);
lines.back() += "...";
}
for (auto &l : lines)
l = fit(font, l, width);
return lines;
}
std::string typed(const std::string &s, uint32_t t, int cps) { return s.substr(0, std::min<size_t>(s.size(), t * cps / 1000)); }
std::string kw(float w) { return std::isnan(w) ? "--" : upper(watts(w)); }
std::string deg(float v) { return std::isnan(v) ? "--" : str("%.1f", v); }
+239
View File
@@ -0,0 +1,239 @@
#include "yourname.h"
#include <algorithm>
#include <cmath>
#include "el512_display.h"
#include "esphome/core/hal.h"
#include "yourname_sprites.h"
namespace esphome::el512::yourname {
using display::COLOR_OFF;
using display::COLOR_ON;
using display::Display;
using display::TextAlign;
namespace {
using namespace fx;
constexpr int W = 512, H = 256;
const uint8_t BAYER[4][4] = {{0, 8, 2, 10}, {12, 4, 14, 6}, {3, 11, 1, 9}, {15, 7, 13, 5}};
bool dither(int x, int y, int level) { return BAYER[y & 3][x & 3] < level; }
// "12° · partly cloudy", for the corner of the comet page.
std::string weather_line(const HomeData &d) {
std::string s = std::isnan(d.temp) ? "" : str("%.0f°", d.temp);
if (!d.weather.empty())
s += (s.empty() ? "" : " · ") + lower(weather_text(d.weather));
return s;
}
// Unix time to local "HH:MM", using now's offset from UTC.
std::string local_hhmm(ESPTime now, int64_t when) {
const int64_t local_secs = now.hour * 3600 + now.minute * 60 + now.second;
const int64_t offset = ((local_secs - (int64_t) now.timestamp % 86400) % 86400 + 86400 + 43200) % 86400 - 43200;
const int64_t s = ((when + offset) % 86400 + 86400) % 86400;
return str("%02d:%02d", (int) (s / 3600), (int) (s / 60 % 60));
}
} // namespace
// --- 1. Comet Tiamat over Itomori ------------------------------------------------------
void comet(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now) {
if (f.jp == nullptr)
return;
const uint32_t t = it.page_age_ms();
constexpr uint32_t CYCLE = 20000;
const float k = (float) (t % CYCLE) / CYCLE;
auto ridge = [](int x) {
return 172 + (int) (12 * std::sin(x * 0.021f) + 8 * std::sin(x * 0.053f + 1.3f) + 4 * std::sin(x * 0.11f + 0.4f));
};
// Stars above the mountains.
for (int i = 0; i < 90; i++) {
const uint32_t r = hash(i, 1200);
const int x = (int) (r % W), y = 40 + (int) ((r >> 9) % 140);
if (y >= ridge(x) - 4 || (t / 200 + (r >> 20)) % 15 == 0)
continue;
it.draw_pixel_at(x, y, COLOR_ON);
}
// The comet crosses the sky and, past halfway, splits in two.
const float hx = 110 + 360 * k, hy = 70 + 60 * k;
const float dx = 360, dy = 60, len = std::sqrt(dx * dx + dy * dy);
const float ux = dx / len, uy = dy / len; // direction of travel
auto draw_comet = [&](float x, float y, float tail, int glow) {
for (int s = -3; s <= 3; s++) {
const float spread = s * 3.5f;
it.line((int) x, (int) y, (int) (x - ux * tail - uy * spread), (int) (y - uy * tail + ux * spread));
}
for (int yy = (int) y - glow; yy <= (int) y + glow; yy++)
for (int xx = (int) x - glow; xx <= (int) x + glow; xx++)
if ((xx - x) * (xx - x) + (yy - y) * (yy - y) <= glow * glow && dither(xx, yy, 10))
it.draw_pixel_at(xx, yy, COLOR_ON);
it.filled_circle((int) x, (int) y, 2);
};
constexpr float SPLIT = 0.55f;
if (k < SPLIT) {
draw_comet(hx, hy, 130, 6);
} else {
const float s = (k - SPLIT) * 120; // how far the fragments have drifted apart
draw_comet(hx, hy, 120, 5);
draw_comet(hx + uy * s * 0.4f, hy + ux * s, 100, 4);
// Sparks shed where it broke up.
const float px = 110 + 360 * SPLIT, py = 70 + 60 * SPLIT;
for (int i = 0; i < 10; i++) {
const uint32_t r = hash(i, (uint32_t) (t / CYCLE));
const float a = (r % 360) * 3.14159f / 180, v = 20 + (r >> 9) % 40;
const float age = (k - SPLIT) * 6;
const int sx = (int) (px + std::cos(a) * v * age), sy = (int) (py + std::sin(a) * v * age + 30 * age * age);
if (sy < ridge(sx) - 2)
it.draw_pixel_at(sx, sy, COLOR_ON);
}
}
// Mountains, misty slopes, and the shrine gate on the left hill.
for (int x = 0; x < W; x++) {
const int y = ridge(x);
it.draw_pixel_at(x, y, COLOR_ON);
if (x > 0)
it.line(x - 1, ridge(x - 1), x, y);
for (int yy = y + 1; yy < H; yy++)
it.draw_pixel_at(x, yy, dither(x, yy, 1) ? COLOR_ON : COLOR_OFF);
}
sprite(it, TORII, 60, ridge(70) - TORII.h - 1);
// The round lake, with the comet's reflection and the town's lights.
constexpr int LX = 300, LY = 222, RX = 110, RY = 18;
for (int y = LY - RY; y <= LY + RY; y++)
for (int x = LX - RX; x <= LX + RX; x++) {
const float e = (float) ((x - LX) * (x - LX)) / (RX * RX) + (float) ((y - LY) * (y - LY)) / (RY * RY);
if (e <= 1.0f)
it.draw_pixel_at(x, y, COLOR_OFF);
}
for (int a = 0; a < 360; a += 3) {
const float r = a * 3.14159f / 180;
it.draw_pixel_at(LX + (int) (RX * std::cos(r)), LY + (int) (RY * std::sin(r)), COLOR_ON);
}
if (hx > LX - RX + 10 && hx < LX + RX - 10)
for (int y = LY - RY + 4; y < LY + RY - 2; y += 3)
it.horizontal_line((int) hx - 3 + (int) ((t / 120 + y) % 5), y, 4 + (y % 3));
for (int i = 0; i < 14; i++) {
const float r = (200 + i * 10) * 3.14159f / 180;
if ((t / 300 + hash(i, 9)) % 7 != 0)
it.filled_rectangle(LX + (int) ((RX + 6) * std::cos(r)), LY + (int) ((RY + 4) * std::sin(r)) - 1, 2, 2);
}
// Title, clock and weather.
it.print(10, 6, f.jp, TextAlign::TOP_LEFT, "君の名は。");
if (now.is_valid())
it.strftime(504, 0, f.clock, TextAlign::TOP_RIGHT, "%H:%M", now);
it.print(504, 50, f.hand, TextAlign::TOP_RIGHT, weather_line(d).c_str());
}
// --- 2. Kataware-doki: the twilight hour -------------------------------------------------
void twilight(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now) {
if (f.jp == nullptr)
return;
const uint32_t t = it.page_age_ms();
const int64_t nowt = now.is_valid() ? (int64_t) now.timestamp : 0;
const float elev = d.sun_elevation;
const float to_sunset = d.sunset > 0 && nowt > 0 ? (d.sunset - nowt) / 60.0f : NAN; // minutes
const float to_sunrise = d.sunrise > 0 && nowt > 0 ? (d.sunrise - nowt) / 60.0f : NAN;
// Golden hour before sunset through civil dusk after it.
const bool dusk = (!std::isnan(elev) && elev > -6 && elev < 4 && !std::isnan(to_sunrise) &&
!std::isnan(to_sunset) && to_sunset < to_sunrise) ||
(!std::isnan(to_sunset) && to_sunset <= 45);
const bool night = !std::isnan(elev) && elev <= -6;
// The crater rim, highest in the middle where they stand (once per frame).
int rims[W];
for (int x = 0; x < W; x++) {
const float u = (x - 256) / 120.0f;
rims[x] = 190 - (int) (12 * std::exp(-u * u));
}
auto rim = [&rims](int x) { return rims[std::clamp(x, 0, W - 1)]; };
// Sky: a glow that thickens towards the horizon at dusk; stars at night.
if (dusk) {
for (int y = 90; y < 190; y++)
for (int x = 0; x < W; x++)
if (y < rim(x) && dither(x, y, (y - 90) / 22))
it.draw_pixel_at(x, y, COLOR_ON);
}
if (night || dusk) {
for (int i = 0; i < (night ? 80 : 30); i++) {
const uint32_t r = hash(i, 777);
const int x = (int) (r % W), y = 60 + (int) ((r >> 9) % (night ? 120 : 50));
if ((t / 250 + (r >> 20)) % 13 != 0)
it.draw_pixel_at(x, y, COLOR_ON);
}
}
// The sun, by its elevation (or by the time left), cut off by the rim.
if (!night) {
float e = elev;
if (std::isnan(e))
e = std::isnan(to_sunset) ? 20 : std::min(40.0f, to_sunset / 12);
const int sx = 256, sy = rim(256) - (int) (e * 6);
for (int y = sy - 24; y <= sy + 24; y++)
for (int x = sx - 24; x <= sx + 24; x++)
if ((x - sx) * (x - sx) + (y - sy) * (y - sy) <= 24 * 24 && y < rim(x) - 1 && dither(x, y, dusk ? 9 : 13))
it.draw_pixel_at(x, y, COLOR_ON);
} else {
it.filled_circle(430, 70, 14);
it.filled_circle(424, 66, 13, COLOR_OFF);
}
// The rim and the dark ground below it.
for (int x = 0; x < W; x++) {
const int y = rim(x);
for (int yy = y; yy < H; yy++)
it.draw_pixel_at(x, yy, dither(x, yy, 2) ? COLOR_ON : COLOR_OFF);
it.draw_pixel_at(x, y, COLOR_ON);
}
if (dusk) {
// Two people on the rim, for as long as the twilight lasts.
// Dark silhouettes against the glow, with a thin lit outline.
const int ty = rim(232) - TAKI.h * 2, my = rim(280) - MITSUHA.h * 2;
for (int o = 0; o < 4; o++) {
const int ox = o == 0 ? -1 : o == 1 ? 1 : 0, oy = o == 2 ? -1 : o == 3 ? 1 : 0;
sprite(it, TAKI, 214 + ox, ty + oy, 2);
sprite(it, MITSUHA, 266 + ox, my + oy, 2);
}
sprite(it, TAKI, 214, ty, 2, -1, COLOR_OFF);
sprite(it, MITSUHA, 266, my, 2, -1, COLOR_OFF);
const int tw = (int) ((t / 160) % 8);
if (tw < 4)
for (int r = 0; r <= tw; r++) {
it.draw_pixel_at(256 - r, ty + 8, COLOR_ON);
it.draw_pixel_at(256 + r, ty + 8, COLOR_ON);
it.draw_pixel_at(256, ty + 8 - r, COLOR_ON);
it.draw_pixel_at(256, ty + 8 + r, COLOR_ON);
}
it.print(10, 6, f.jp, TextAlign::TOP_LEFT, "かたわれ時");
it.print(12, 44, f.hand, TextAlign::TOP_LEFT, "kataware-doki, when the worlds blur");
} else {
it.print(10, 6, f.hand, TextAlign::TOP_LEFT, night ? "the sun is down" : "waiting for twilight");
}
// Countdown to sunset (or, at night, to sunrise), and today's times.
const bool to_rise = night || (!std::isnan(to_sunset) && !std::isnan(to_sunrise) && to_sunrise < to_sunset);
const float mins = to_rise ? to_sunrise : to_sunset;
if (!std::isnan(mins) && mins >= 0) {
it.print(504, 4, f.hand, TextAlign::TOP_RIGHT, to_rise ? "sunrise in" : "sunset in");
it.printf(504, 26, f.clock, TextAlign::TOP_RIGHT, "%d:%02d", (int) (mins / 60), (int) mins % 60);
}
if (now.is_valid() && d.sunset > 0 && d.sunrise > 0)
it.printf(504, 234, f.mono, TextAlign::TOP_RIGHT, "SUNSET %s SUNRISE %s", local_hhmm(now, d.sunset).c_str(),
local_hhmm(now, d.sunrise).c_str());
else if (d.sunset == 0)
it.print(504, 234, f.mono, TextAlign::TOP_RIGHT, "NO SUN DATA (SUN.SUN)");
}
} // namespace esphome::el512::yourname
+32
View File
@@ -0,0 +1,32 @@
// Your Name (Kimi no Na wa)-themed animated pages for the ESPHome display: the
// comet splitting over Itomori's lake, and kataware-doki, the twilight hour,
// counting down to sunset from Home Assistant's sun. Fan art: the pixel art
// (yourname_sprites.h, from tools/yourname_sprites.py), drawings and texts
// are original.
//
// Each function draws a whole page. Call it from a page lambda and list that
// page under animated_pages; the animation clock is the display's page_age_ms().
#pragma once
#include "el512_pages.h"
#include "esphome/components/display/display.h"
#include "esphome/core/time.h"
namespace esphome::el512 {
class EL512Display;
namespace yourname {
struct Fonts {
display::BaseFont *jp{nullptr}; // brush lettering (Yuji Syuku)
display::BaseFont *hand{nullptr}; // handwriting (Caveat)
display::BaseFont *clock{nullptr}; // clock digits (Cormorant Garamond)
display::BaseFont *mono{nullptr}; // small print (Share Tech Mono 14)
};
void comet(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now);
void twilight(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now);
} // namespace yourname
} // namespace esphome::el512
+65
View File
@@ -0,0 +1,65 @@
// Pixel art for the Your Name pages, generated by tools/yourname_sprites.py;
// edit the art there. Original 1-bit fan art, '#' = lit pixel.
#pragma once
#include "el512_pages.h" // Sprite
namespace esphome::el512::yourname {
static const char *const TORII_ROWS[] = {
"####################",
".##################.",
"...##..........##...",
"..################..",
"...##..........##...",
"...##..........##...",
"...##..........##...",
"...##..........##...",
"...##..........##...",
"..####........####..",
};
static constexpr Sprite TORII{20, 10, TORII_ROWS};
static const char *const TAKI_ROWS[] = {
"...####...",
"..######..",
"..######..",
"..#####...",
"...###....",
"..#####...",
".#######..",
".#######..",
".##.###...",
".##.###...",
"....###...",
"...####...",
"...#..#...",
"...#..#...",
"...#..#...",
"...#..#...",
"..##..##..",
};
static constexpr Sprite TAKI{10, 17, TAKI_ROWS};
static const char *const MITSUHA_ROWS[] = {
"...####...",
"..######..",
"..######..",
"...#####..",
"....###.##",
"...#####.#",
"..#######.",
"..#######.",
"...###.##.",
"..#####...",
".#######..",
".#######..",
"...#..#...",
"...#..#...",
"...#..#...",
"...#..#...",
"..##..##..",
};
static constexpr Sprite MITSUHA{10, 17, MITSUHA_ROWS};
} // namespace esphome::el512::yourname
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

+91 -4
View File
@@ -22,6 +22,8 @@ esphome:
id(f_ks_jp_big), id(icons_l)};
id(sg_fonts) = {id(f_sg_nixie), id(f_sg_title), id(f_cp_mono), id(f_cp_code), id(f_ks_pixel), id(f_sg_jp)};
id(st_fonts) = {id(f_st_small), id(f_st_medium), id(f_st_large), id(f_ks_text), id(f_ks_pixel)};
id(is_fonts) = {id(f_is_title), id(f_is_large), id(f_cp_mono), id(f_cp_code)};
id(yn_fonts) = {id(f_yn_jp), id(f_yn_hand), id(f_yn_clock), id(f_cp_mono)};
esp32:
board: wemos_d1_mini32
@@ -67,7 +69,7 @@ globals:
- id: plex_updated
type: uint32_t
initial_value: "0"
# Themed pages (Cyberpunk, KonoSuba, Steins;Gate, Star Trek): their fonts and the home data they show.
# Themed pages (Cyberpunk, KonoSuba, Steins;Gate, Star Trek, Interstellar, Your Name): their fonts and the home data they show.
- id: cp_fonts
type: el512::cyber::Fonts
- id: ks_fonts
@@ -76,6 +78,10 @@ globals:
type: el512::steins::Fonts
- id: st_fonts
type: el512::trek::Fonts
- id: is_fonts
type: el512::interstellar::Fonts
- id: yn_fonts
type: el512::yourname::Fonts
- id: cp_home
type: el512::HomeData
@@ -174,7 +180,7 @@ font:
- file: "gfonts://Share Tech Mono"
id: f_cp_code
size: 22
glyphs: " 0123456789ABCDEF.%"
glyphsets: [GF_Latin_Core]
# KonoSuba pages
- file: "gfonts://Cinzel@700"
id: f_ks_title
@@ -231,6 +237,28 @@ font:
id: f_st_large
size: 60
glyphs: " 0123456789:ADELRT"
# Interstellar pages (also use f_cp_mono and f_cp_code)
- file: "gfonts://Michroma"
id: f_is_title
size: 12
glyphsets: [GF_Latin_Core]
- file: "gfonts://Michroma"
id: f_is_large
size: 28
glyphs: " 0123456789:.ARST"
# Your Name pages (also use f_cp_mono)
- file: "gfonts://Yuji Syuku"
id: f_yn_jp
size: 26
glyphs: "君の名は。かたわれ時"
- file: "gfonts://Caveat@700"
id: f_yn_hand
size: 22
glyphsets: [GF_Latin_Core]
- file: "gfonts://Cormorant Garamond@600"
id: f_yn_clock
size: 48
glyphs: "0123456789:"
# --- Data from Home Assistant -------------------------------------------------
text_sensor:
@@ -266,6 +294,14 @@ text_sensor:
- platform: homeassistant
id: printer_state
entity_id: ${printer}
- platform: homeassistant
id: sun_setting
entity_id: sun.sun
attribute: next_setting
- platform: homeassistant
id: sun_rising
entity_id: sun.sun
attribute: next_rising
- platform: homeassistant
id: plex_update
entity_id: update.plex_media_server_mangavalkserver
@@ -294,6 +330,10 @@ sensor:
- platform: homeassistant
id: tautulli_bandwidth
entity_id: sensor.tautulli_total_bandwidth
- platform: homeassistant
id: sun_elev
entity_id: sun.sun
attribute: elevation
# Solar (Enphase Envoy) and grid
- platform: homeassistant
id: solar_power
@@ -439,6 +479,12 @@ select:
- Duty Roster
- Warp Core
- Captain's Log
- Gargantua
- TARS
- Endurance
- Tesseract
- Comet Tiamat
- Kataware-doki
initial_option: Clock
set_action:
- lambda: |-
@@ -467,6 +513,12 @@ select:
else if (x == "Duty Roster") p = id(page_st_roster);
else if (x == "Warp Core") p = id(page_st_warp);
else if (x == "Captain's Log") p = id(page_st_log);
else if (x == "Gargantua") p = id(page_is_gargantua);
else if (x == "TARS") p = id(page_is_tars);
else if (x == "Endurance") p = id(page_is_endurance);
else if (x == "Tesseract") p = id(page_is_tesseract);
else if (x == "Comet Tiamat") p = id(page_yn_comet);
else if (x == "Kataware-doki") p = id(page_yn_twilight);
id(panel).show_page(p);
id(panel).update();
@@ -506,7 +558,9 @@ interval:
id(page_plex), id(page_solar), id(page_cyber_hud), id(page_cyber_breach), id(page_cyber_scan),
id(page_cyber_split), id(page_ks_explosion), id(page_ks_guild), id(page_ks_card),
id(page_ks_battle), id(page_sg_meter), id(page_sg_dmail), id(page_sg_lab), id(page_sg_world),
id(page_st_lcars), id(page_st_roster), id(page_st_warp), id(page_st_log), id(page_still_alive)};
id(page_st_lcars), id(page_st_roster), id(page_st_warp), id(page_st_log),
id(page_is_gargantua), id(page_is_tars), id(page_is_endurance), id(page_is_tesseract),
id(page_yn_comet), id(page_yn_twilight), id(page_still_alive)};
for (size_t i = order.size() - 1; i > 0; i--)
std::swap(order[i], order[random_uint32() % (i + 1)]);
pos = order.size() - 1; // the next step starts at the front
@@ -535,6 +589,9 @@ interval:
d.agenda = id(agenda_events).state;
d.message = id(panel_message).state;
d.printer = id(printer_state).state;
d.sunset = el512::fx::parse_iso_utc(id(sun_setting).state);
d.sunrise = el512::fx::parse_iso_utc(id(sun_rising).state);
d.sun_elevation = id(sun_elev).state;
d.house = id(house_power).state;
d.grid = id(grid_import).state - id(grid_export).state;
d.rooms[0] = {"Woonkamer", id(t_woonkamer).state};
@@ -555,7 +612,9 @@ display:
animated_pages: [page_cyber_hud, page_cyber_breach, page_cyber_scan, page_cyber_split,
page_ks_explosion, page_ks_guild, page_ks_card, page_ks_battle,
page_sg_meter, page_sg_dmail, page_sg_lab, page_sg_world,
page_st_lcars, page_st_roster, page_st_warp, page_st_log]
page_st_lcars, page_st_roster, page_st_warp, page_st_log,
page_is_gargantua, page_is_tars, page_is_endurance, page_is_tesseract,
page_yn_comet, page_yn_twilight]
animation_interval: 66ms
on_page_change:
- then:
@@ -584,6 +643,12 @@ display:
else if (to == id(page_st_roster)) name = "Duty Roster";
else if (to == id(page_st_warp)) name = "Warp Core";
else if (to == id(page_st_log)) name = "Captain's Log";
else if (to == id(page_is_gargantua)) name = "Gargantua";
else if (to == id(page_is_tars)) name = "TARS";
else if (to == id(page_is_endurance)) name = "Endurance";
else if (to == id(page_is_tesseract)) name = "Tesseract";
else if (to == id(page_yn_comet)) name = "Comet Tiamat";
else if (to == id(page_yn_twilight)) name = "Kataware-doki";
if (id(page_select).current_option() != name) id(page_select).publish_state(name);
pages:
# 1. Clock, date and current weather
@@ -853,3 +918,25 @@ display:
- id: page_st_log
lambda: |-
el512::trek::captains_log(*id(panel), id(st_fonts), id(cp_home), id(ha_time).now());
# 25-28. Interstellar pages (components/el512/interstellar.cpp)
- id: page_is_gargantua
lambda: |-
el512::interstellar::gargantua(*id(panel), id(is_fonts), id(ha_time).now());
- id: page_is_tars
lambda: |-
el512::interstellar::tars(*id(panel), id(is_fonts), id(cp_home));
- id: page_is_endurance
lambda: |-
el512::interstellar::endurance(*id(panel), id(is_fonts), id(cp_home), id(ha_time).now());
- id: page_is_tesseract
lambda: |-
el512::interstellar::tesseract(*id(panel), id(is_fonts), id(cp_home), id(ha_time).now());
# 29-30. Your Name pages (components/el512/yourname.cpp)
- id: page_yn_comet
lambda: |-
el512::yourname::comet(*id(panel), id(yn_fonts), id(cp_home), id(ha_time).now());
- id: page_yn_twilight
lambda: |-
el512::yourname::twilight(*id(panel), id(yn_fonts), id(cp_home), id(ha_time).now());
+2
View File
@@ -12,9 +12,11 @@
#include "el512_helpers.h"
#include "esphome/components/display/display.h"
#include "esphome/core/hal.h"
#include "interstellar.h"
#include "konosuba.h"
#include "steins.h"
#include "trek.h"
#include "yourname.h"
namespace esphome::el512 {
+14
View File
@@ -50,6 +50,10 @@ static HomeData sample(const std::string &scenario) {
d.rooms[1] = {"Voorkamer", 19.8f};
d.rooms[2] = {"Patrick", 20.5f};
d.rooms[3] = {"Badkamer", 22.1f};
// Sunset 40 minutes after the sample time: twilight.
d.sunset = 1792420645 + 40 * 60;
d.sunrise = 1792420645 + 17 * 3600;
d.sun_elevation = 3.5f;
if (scenario == "busy") {
d.weather = "lightning-rainy";
d.temp = -12.6f;
@@ -65,6 +69,8 @@ static HomeData sample(const std::string &scenario) {
"Thu 22|19:00|Dinner";
d.rooms[0] = {"Vloerverwarming", 23.9f};
d.rooms[1] = {"Stans & Paul", 18.2f};
d.sunset = 1792420645 + 3 * 3600 + 25 * 60;
d.sun_elevation = 24.0f;
}
// Today's solar curve up to now (14:37), for the world line chart.
for (int s = 7 * 4; s <= 14 * 4 + 2; s++) {
@@ -85,6 +91,8 @@ static std::vector<Page> pages() {
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};
const interstellar::Fonts is{f_is_title, f_is_large, f_cp_mono, f_cp_code};
const yourname::Fonts yn{f_yn_jp, f_yn_hand, f_yn_clock, f_cp_mono};
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); }},
@@ -103,6 +111,12 @@ static std::vector<Page> pages() {
{"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); }},
{"is_gargantua", {3000}, [=](auto &it, auto &, auto now) { interstellar::gargantua(it, is, now); }},
{"is_tars", {3500, 38500}, [=](auto &it, auto &d, auto) { interstellar::tars(it, is, d); }},
{"is_endurance", {8000, 2000, 12000}, [=](auto &it, auto &d, auto now) { interstellar::endurance(it, is, d, now); }},
{"is_tesseract", {1700, 900}, [=](auto &it, auto &d, auto now) { interstellar::tesseract(it, is, d, now); }},
{"yn_comet", {14000, 6000}, [=](auto &it, auto &d, auto now) { yourname::comet(it, yn, d, now); }},
{"yn_twilight", {3000}, [=](auto &it, auto &d, auto now) { yourname::twilight(it, yn, d, now); }},
};
}
+5 -3
View File
@@ -2,7 +2,8 @@
"""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)
Compiles the page code (components/el512/cyberpunk, konosuba, steins, trek,
interstellar, yourname)
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
@@ -29,8 +30,9 @@ 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"]
PAGE_SOURCES = ["cyberpunk", "konosuba", "steins", "trek", "interstellar", "yourname"]
PAGE_HEADERS = ["el512_pages.h", "el512_helpers.h", "konosuba_sprites.h", "steins_sprites.h", "trek_sprites.h",
"yourname_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",
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Pixel art for the Your Name pages (components/el512/yourname.cpp).
Original 1-bit fan art: '#' is a lit pixel. Writes
components/el512/yourname_sprites.h and, with --preview, a magnified PNG.
python tools/yourname_sprites.py
python tools/yourname_sprites.py --preview sprites.png
"""
from konosuba_sprites import main, padded
SPRITES = {
# Shrine gate on the hill above the lake.
"TORII": [
"####################",
".##################.",
"...##..........##...",
"..################..",
"...##..........##...",
"...##..........##...",
"...##..........##...",
"...##..........##...",
"...##..........##...",
"..####........####..",
],
# Two silhouettes on the crater rim at twilight: him facing right...
"TAKI": [
"...####...",
"..######..",
"..######..",
"..#####...",
"...###....",
"..#####...",
".#######..",
".#######..",
".##.###...",
".##.###...",
"....###...",
"...####...",
"...#..#...",
"...#..#...",
"...#..#...",
"...#..#...",
"..##..##..",
],
# ... and her facing left, the cord tied in her hair, a skirt.
"MITSUHA": [
"...####...",
"..######..",
"..######..",
"...#####..",
"....###.##",
"...#####.#",
"..#######.",
"..#######.",
"...###.##.",
"..#####...",
".#######..",
".#######..",
"...#..#...",
"...#..#...",
"...#..#...",
"...#..#...",
"..##..##..",
],
}
for _name in SPRITES:
SPRITES[_name] = padded(SPRITES[_name])
if __name__ == "__main__":
main(SPRITES, "Your Name", "yourname_sprites.py", "yourname", __doc__)