From 50ee17b3f63918bf084a16a05e4f16f976de5e1c Mon Sep 17 00:00:00 2001 From: MangaValk Date: Sun, 18 Oct 2026 23:29:12 +0200 Subject: [PATCH] Add four animated Cyberpunk-style ESPHome pages Netrunner HUD, self-solving breach protocol, optics boot-and-scan and a split HUD with a mini breach matrix, all in the page rotation. The el512 display gains animated_pages / animation_interval (redraw ~15 fps while shown), page_age_ms() as animation clock, and shift_rows() / invert_rect() for glitch effects. Co-Authored-By: Claude Opus 5.5 --- README.md | 34 +- components/el512/cyberpunk.cpp | 687 +++++++++++++++++++++++++++++ components/el512/cyberpunk.h | 55 +++ components/el512/display.py | 12 + components/el512/el512_display.cpp | 56 ++- components/el512/el512_display.h | 24 + esphome/el512-panel.yaml | 80 +++- 7 files changed, 944 insertions(+), 4 deletions(-) create mode 100644 components/el512/cyberpunk.cpp create mode 100644 components/el512/cyberpunk.h diff --git a/README.md b/README.md index 307eab8..729d24e 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ components/el512/ shared driver + ESPHome component (one copy of the code) el512_panel.* framebuffer, DMA ring, refresh interrupt (ESP-IDF API) still_alive.* Portal end-credits show, 5x7 font (el512_font.h) still_alive_data.h show content, generated by tools/import_still_alive.py + cyberpunk.* animated Cyberpunk-style ESPHome pages el512_display.* ESPHome display platform `el512` __init__.py, display.py ESPHome config schema lib/EL512/ Arduino library: Adafruit_GFX front end (EL512_GFX) @@ -108,8 +109,9 @@ pio device monitor ## ESPHome / Home Assistant [esphome/el512-panel.yaml](esphome/el512-panel.yaml) turns the panel into a Home -Assistant display with eight pages: clock and weather, forecast, agenda, a sensor -dashboard with a message line, now playing, Plex, solar, and Still Alive. Pages +Assistant display with twelve pages: clock and weather, forecast, agenda, a sensor +dashboard with a message line, now playing, Plex, solar, Still Alive, and four +animated [Cyberpunk-style pages](#cyberpunk-pages). Pages rotate on an interval; 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). @@ -130,6 +132,8 @@ display: enable_pin: GPIO25 # optional, J2-6 low_power_pin: GPIO26 # optional, J2-5 still_alive_page: page_still_alive # optional + animated_pages: [page_cyber_hud] # optional: redraw these while shown + animation_interval: 66ms # optional, default 66ms (~15 fps) pages: ... ``` @@ -185,6 +189,32 @@ They are included only for personal, non-commercial use in this hobby project, a all rights remain with their owners. This project is not affiliated with or endorsed by Valve. +## Cyberpunk pages + +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: + +- **Cyber HUD**: clock, outside weather, power (solar, house, grid) and four rooms + in cut-corner frames, with a scrolling ticker. +- **Cyber Breach**: a breach-protocol code matrix that solves itself: the cursor + walks the active row or column, codes fill the buffer, daemons upload, then a new + puzzle starts. +- **Cyber Scan**: an optics boot log with live values, a calibration zoom, then a + rotating reticle around the clock and a typed-out scan of the house. +- **Cyber Split**: compact clock and readouts on the left, a small breach matrix on + the right. + +They are listed under `animated_pages`, so they redraw about 15 times a second +while shown; each page starts its intro (wipe-in, glitches) when it appears. The +fonts and Home Assistant values come from the globals `cp_fonts` and `cp_home` in +`el512-panel.yaml`. To keep only some of them, remove the others from `pages`, +`animated_pages`, the page select, `on_page_change` and the rotation loop count. + +These pages are inspired by the look of Cyberpunk 2077 and use no assets from it. +Cyberpunk 2077 is a trademark of CD PROJEKT S.A.; this project is not affiliated +with or endorsed by CD PROJEKT RED. + ## Using the driver ```cpp diff --git a/components/el512/cyberpunk.cpp b/components/el512/cyberpunk.cpp new file mode 100644 index 0000000..a627750 --- /dev/null +++ b/components/el512/cyberpunk.cpp @@ -0,0 +1,687 @@ +#include "cyberpunk.h" + +#include +#include +#include +#include +#include +#include + +#include "el512_display.h" +#include "esphome/core/hal.h" + +namespace esphome::el512::cyber { + +using display::COLOR_OFF; +using display::COLOR_ON; +using display::Display; +using display::TextAlign; + +namespace { + +constexpr int W = 512, H = 256; + +// --- Small helpers ----------------------------------------------------------- + +// Integer hash for repeatable pseudo-random effects (lowbias32). +uint32_t hash(uint32_t x) { + x ^= x >> 16; + x *= 0x7feb352d; + x ^= x >> 15; + x *= 0x846ca68b; + x ^= x >> 16; + return x; +} +uint32_t hash(uint32_t a, uint32_t b) { return hash(a * 0x9e3779b9u ^ hash(b)); } + +std::string str(const char *fmt, ...) __attribute__((format(printf, 1, 2))); +std::string str(const char *fmt, ...) { + char buf[96]; + va_list args; + va_start(args, fmt); + vsnprintf(buf, sizeof(buf), fmt, args); + va_end(args); + return buf; +} + +std::string upper(std::string s) { + for (auto &c : s) + c = (char) toupper((unsigned char) c); + return s; +} + +std::string temp_text(float v, const char *unit = "°") { return std::isnan(v) ? "--" : str("%.1f%s", v, unit); } +std::string watts_text(float v) { return std::isnan(v) ? "--" : watts(v); } + +int text_width(Display &it, display::BaseFont *font, const char *text) { + int x, y, w, h; + it.get_text_bounds(0, 0, text, font, TextAlign::TOP_LEFT, &x, &y, &w, &h); + return w; +} + +const char *const CODES[] = {"1C", "55", "BD", "E9", "7A", "FF"}; +constexpr int CODE_COUNT = 6; + +std::string hex_noise(uint32_t seed, int pairs) { + std::string s; + for (int i = 0; i < pairs; i++) { + if (i > 0) + s += ' '; + s += CODES[hash(seed, i) % CODE_COUNT]; + } + return s; +} + +// --- Graphic vocabulary ------------------------------------------------------- + +// Frame with the top-left and bottom-right corners cut off at 45 degrees. +void cut_frame(Display &it, int x, int y, int w, int h, int cut = 8) { + const int r = x + w - 1, b = y + h - 1; + it.line(x + cut, y, r, y); + it.line(r, y, r, b - cut); + it.line(r, b - cut, r - cut, b); + it.line(r - cut, b, x, b); + it.line(x, b, x, y + cut); + it.line(x, y + cut, x + cut, y); +} + +// Lit label with a slanted right end and the text cut out of it; returns the +// width including the slant. +int tag(Display &it, display::BaseFont *font, int x, int y, int h, const char *text) { + const int w = text_width(it, font, text) + 10; + it.filled_rectangle(x, y, w, h); + it.filled_triangle(x + w, y, x + w, y + h - 1, x + w + h - 1, y); + it.print(x + 5, y + h / 2, font, COLOR_OFF, TextAlign::CENTER_LEFT, text); + return w + h; +} + +// Corner brackets around a rectangle. +void brackets(Display &it, int x, int y, int w, int h, int len) { + const int r = x + w - 1, b = y + h - 1; + it.horizontal_line(x, y, len); + it.vertical_line(x, y, len); + it.horizontal_line(r - len + 1, y, len); + it.vertical_line(r, y, len); + it.horizontal_line(x, b, len); + it.vertical_line(x, b - len + 1, len); + it.horizontal_line(r - len + 1, b, len); + it.vertical_line(r, b - len + 1, len); +} + +// Segmented bar lit up to frac (0..1); unlit segments are a tick on the base. +void seg_bar(Display &it, int x, int y, int w, int h, float frac, int seg = 6) { + const int n = w / seg; + const int 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 * seg, y, seg - 2, h); + else + it.horizontal_line(x + i * seg, y + h - 1, seg - 2); + } +} + +// Diagonal warning stripes that crawl sideways with phase. +void hatch(Display &it, int x, int y, int w, int h, int phase) { + for (int py = 0; py < h; py++) + for (int px = 0; px < w; px++) + if (((px + py + phase) & 7) < 3) + it.draw_pixel_at(x + px, y + py, COLOR_ON); +} + +// Text scrolling right to left between x0 and x1, 40 px/s. +void ticker(Display &it, display::BaseFont *font, int x0, int x1, int baseline, uint32_t t, const std::string &text) { + const int span = text_width(it, font, text.c_str()) + (x1 - x0); + const int x = x1 - (int) ((t / 25) % (uint32_t) span); + it.start_clipping(x0, 0, x1, H); + it.print(x, baseline, font, TextAlign::BASELINE_LEFT, text.c_str()); + it.end_clipping(); +} + +// Page intro: the drawing is revealed left to right behind a bright scan line. +void wipe_in(EL512Display &it, uint32_t t, uint32_t dur = 450) { + if (t >= dur) + return; + const int x = (int) (W * t / dur); + it.filled_rectangle(x, 0, W - x, H, COLOR_OFF); + it.vertical_line(x, 0, H); + it.vertical_line(x + 3, 0, H); +} + +// Glitch: right after the page appears, then a short burst every few seconds +// where random bands of rows jump sideways and thin slivers flash inverted. +void glitch(EL512Display &it, uint32_t t) { + const uint32_t burst = t / 3700, in = t % 3700; + int bands = 0; + if (t < 350) + bands = 6; + else if (in < 200 && hash(burst, 7) % 3 != 0) + bands = 1 + (int) (hash(burst, 1) % 3); + const uint32_t frame = t / 50; + for (int i = 0; i < bands; i++) { + const uint32_t r = hash(frame, i); + const int y = (int) (r % H), h = 2 + (int) ((r >> 9) % 14), dx = (int) ((r >> 16) % 49) - 24; + it.shift_rows(y, h, dx); + if ((r >> 25) % 3 == 0) + it.invert_rect((int) ((r >> 4) % 400), y, 40 + (int) ((r >> 12) % 120), 2); + } +} + +// Random flickering blocks, used while a page "powers up". +void noise_blocks(Display &it, uint32_t t, int count) { + const uint32_t frame = t / 40; + for (int i = 0; i < count; i++) { + const uint32_t r = hash(frame, 100 + i); + it.filled_rectangle((int) (r % W), (int) ((r >> 9) % H), 4 + (int) ((r >> 18) % 60), 1 + (int) ((r >> 24) % 6)); + } +} + +// --- Breach protocol ----------------------------------------------------------- +// +// A random code matrix with a solution path built in: pick 0 is in the top +// row, then picks alternate between the column and the row of the previous +// one, as in the game. The daemons are runs of codes along that path, so the +// path uploads all of them. Everything is a function of the time since the +// puzzle started, so nothing has to be stored between frames. + +constexpr int PATH = 6; // picks, and buffer size +constexpr int MAX_N = 6; // matrix size +constexpr uint32_t INTRO = 1200; // codes settle into the matrix +constexpr uint32_t PICK = 1000; // per pick: cursor moves, then selects +constexpr uint32_t SELECT_AT = 620; +constexpr uint32_t RESULT = 3200; // "upload complete" +constexpr uint32_t CYCLE = INTRO + PATH * PICK + RESULT; +constexpr float TIME_LIMIT = 30.0f; // s on the breach timer + +struct Daemon { + const char *name; + uint8_t start, len; // run of path codes +}; +constexpr Daemon DAEMONS[] = {{"DATAMINE_V1", 0, 2}, {"ICEPICK", 1, 3}, {"MASS_VULNERABILITY", 3, 3}}; +constexpr int DAEMON_COUNT = 3; + +struct Puzzle { + int n{MAX_N}; + uint8_t grid[MAX_N][MAX_N]{}; + uint8_t pr[PATH]{}, pc[PATH]{}; // solution path + + uint8_t code(int k) const { return this->grid[this->pr[k]][this->pc[k]]; } + // Index of (r, c) on the path among the first `count` picks, or -1. + int path_index(int r, int c, int count) const { + for (int k = 0; k < count; k++) + if (this->pr[k] == r && this->pc[k] == c) + return k; + return -1; + } + + void generate(uint32_t seed, int size) { + this->n = size; + uint32_t s = seed; + auto rnd = [&s](int m) { + s = hash(s + 0x6d2b79f5u); + return (int) (s % (uint32_t) m); + }; + for (int r = 0; r < size; r++) + for (int c = 0; c < size; c++) + this->grid[r][c] = (uint8_t) rnd(CODE_COUNT); + for (bool ok = false; !ok;) { + this->pr[0] = 0; + this->pc[0] = (uint8_t) rnd(size); + ok = true; + for (int k = 1; k < PATH && ok; k++) { + ok = false; + for (int attempt = 0; attempt < 24 && !ok; attempt++) { + int r = this->pr[k - 1], c = this->pc[k - 1]; + if (k & 1) + r = rnd(size); // odd picks move along the column + else + c = rnd(size); // even picks move along the row + if (this->path_index(r, c, k) >= 0) + continue; + this->pr[k] = (uint8_t) r; + this->pc[k] = (uint8_t) c; + ok = true; + } + } + } + } +}; + +// What the breach looks like at time `local` into the puzzle. +struct BreachView { + uint32_t local{0}; + int pick{0}; // pick in progress, PATH when solved + int sel{0}; // codes in the buffer + int hover{-1}; // cursor index along the active line, -1 = none + bool flash{false}; // the cell just selected is blinking + bool row_line{true}; + int line{0}; // active row or column + float remain{TIME_LIMIT}; + + bool settled(int col) const { return this->local >= 250 + (uint32_t) col * 150; } + bool done() const { return this->pick >= PATH; } +}; + +BreachView breach_view(const Puzzle &p, uint32_t local) { + BreachView v; + v.local = local; + if (local < INTRO) + return v; + const uint32_t u = local - INTRO; + const uint32_t solve = std::min(u, PATH * PICK); + if (solve > SELECT_AT) + v.remain = std::max(0.0f, TIME_LIMIT - (solve - SELECT_AT) / 1000.0f); + v.pick = std::min(u / PICK, PATH); + if (v.done()) { + v.sel = PATH; + return v; + } + const int k = v.pick; + const uint32_t w = u % PICK; + v.sel = k + (w >= SELECT_AT ? 1 : 0); + v.row_line = (k & 1) == 0; + v.line = v.row_line ? (k == 0 ? 0 : p.pr[k - 1]) : p.pc[k - 1]; + const int target = v.row_line ? p.pc[k] : p.pr[k]; + const int from = k == 0 ? 0 : (v.row_line ? p.pc[k - 1] : p.pr[k - 1]); + const int steps = (int) (w / 110); + if (w >= SELECT_AT) { + v.hover = target; + v.flash = w < SELECT_AT + 300 && ((w / 50) & 1); + } else if (target == from) { + // Already on it: look at the neighbour first, then come back. + const int alt = from + 1 < p.n ? from + 1 : from - 1; + v.hover = (steps == 1 || steps == 2) ? alt : from; + } else { + const int d = target - from; + v.hover = from + (d > 0 ? 1 : -1) * std::min(steps, std::abs(d)); + } + return v; +} + +struct MatrixLayout { + int x, y; // top-left of the first cell + int cw, ch; // cell size + display::BaseFont *font; +}; + +void draw_matrix(Display &it, const MatrixLayout &L, const Puzzle &p, const BreachView &v) { + const int n = p.n; + // Active row or column. + if (v.local >= INTRO && !v.done()) { + if (v.row_line) { + const int y = L.y + v.line * L.ch; + it.rectangle(L.x - 3, y, n * L.cw + 6, L.ch); + it.filled_triangle(L.x - 9, y + L.ch / 2 - 4, L.x - 9, y + L.ch / 2 + 4, L.x - 5, y + L.ch / 2); + } else { + const int x = L.x + v.line * L.cw; + it.rectangle(x, L.y - 3, L.cw, n * L.ch + 6); + it.filled_triangle(x + L.cw / 2 - 4, L.y - 9, x + L.cw / 2 + 4, L.y - 9, x + L.cw / 2, L.y - 5); + } + } + const uint32_t frame = v.local / 60; + for (int r = 0; r < n; r++) { + for (int c = 0; c < n; c++) { + const int x = L.x + c * L.cw, y = L.y + r * L.ch; + const int cx = x + L.cw / 2, cy = y + L.ch / 2; + const bool on_line = v.local >= INTRO && !v.done() && (v.row_line ? r == v.line : c == v.line); + const bool hovered = on_line && v.hover == (v.row_line ? c : r); + const int idx = p.path_index(r, c, v.sel); + const bool used = idx >= 0; + const char *code = CODES[v.settled(c) ? p.grid[r][c] : hash(frame, r * MAX_N + c) % CODE_COUNT]; + if (v.local < INTRO && !v.settled(c) && hash(frame, 50 + r * MAX_N + c) % 3 == 0) + continue; // still flickering in + if (hovered && !(used && v.flash)) { + it.filled_rectangle(x + 3, y + 2, L.cw - 6, L.ch - 4); + if (!used || idx == v.sel - 1) + it.print(cx, cy, L.font, COLOR_OFF, TextAlign::CENTER, code); + } else if (used) { + brackets(it, cx - 8, cy - 7, 16, 14, 4); + } else { + it.print(cx, cy, L.font, TextAlign::CENTER, code); + } + } + } +} + +// Buffer slots: the codes picked so far. +void draw_buffer(Display &it, display::BaseFont *font, int x, int y, int slot, int h, const Puzzle &p, int sel) { + for (int i = 0; i < PATH; i++) { + const int sx = x + i * (slot + 4); + if (i < sel) { + it.rectangle(sx, y, slot, h); + it.print(sx + slot / 2, y + h / 2, font, TextAlign::CENTER, CODES[p.code(i)]); + } else { + // Dashed empty slot. + for (int d = 0; d < slot; d += 4) { + it.horizontal_line(sx + d, y, 2); + it.horizontal_line(sx + d, y + h - 1, 2); + } + for (int d = 0; d < h; d += 4) { + it.vertical_line(sx, y + d, 2); + it.vertical_line(sx + slot - 1, y + d, 2); + } + } + } +} + +int daemon_progress(const Daemon &dm, int sel) { return std::clamp(sel - (int) dm.start, 0, (int) dm.len); } + +// A different puzzle every cycle and every time the page is shown. +uint32_t breach_seed(EL512Display &it, uint32_t t, uint32_t salt) { + return hash(t / CYCLE, salt ^ it.page_shown_at()); +} + +// --- Shared HUD pieces ----------------------------------------------------------- + +// Big clock, seconds bar and date line in the top-left corner. +void clock_block(Display &it, const Fonts &f, ESPTime now, int width) { + if (!now.is_valid()) { + it.print(4, 104, f.value, TextAlign::BASELINE_LEFT, "--:--"); + return; + } + it.strftime(2, 110, f.huge, TextAlign::BASELINE_LEFT, "%H:%M", now); + seg_bar(it, 4, 120, width - 4, 6, now.second / 59.0f, 8); + it.print(4, 144, f.mono, TextAlign::BASELINE_LEFT, upper(now.strftime("%a %d.%m.%Y // WK %V")).c_str()); +} + +const char *grid_word(float grid) { return std::isnan(grid) ? "GRID" : (grid > 0 ? "GRID IMPORT" : "GRID EXPORT"); } + +} // namespace + +// --- 1. Netrunner HUD: live home data in Cyberpunk dress ------------------------ + +void netrunner_hud(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now) { + if (f.huge == nullptr) + return; + const uint32_t t = it.page_age_ms(); + // Top bar: tag, stepped rule, status line. + const int tw = tag(it, f.label, 0, 0, 18, "NETRUNNER // HUD"); + it.horizontal_line(tw + 2, 17, 330 - tw - 2); + it.line(330, 17, 340, 7); + it.horizontal_line(340, 7, 172); + const uint32_t up = millis() / 1000; + const std::string status = + str("LINK UP %02u:%02u:%02u", (unsigned) (up / 3600 % 100), (unsigned) (up / 60 % 60), (unsigned) (up % 60)); + it.print(508, 21, f.mono, TextAlign::BASELINE_RIGHT, status.c_str()); + if ((t / 500) % 2) + it.filled_rectangle(508 - text_width(it, f.mono, status.c_str()) + text_width(it, f.mono, "LINK "), 12, 8, 8); + + // Left: clock, environment. + clock_block(it, f, now, 244); + cut_frame(it, 0, 154, 244, 82); + tag(it, f.mono, 0, 154, 14, "ENV"); + it.print(12, 202, f.value, TextAlign::BASELINE_LEFT, temp_text(d.temp, "°C").c_str()); + it.print(12, 226, f.label, TextAlign::BASELINE_LEFT, upper(weather_text(d.weather)).c_str()); + if (!std::isnan(d.humidity)) + it.printf(234, 186, f.mono, TextAlign::BASELINE_RIGHT, "HUM %.0f%%", d.humidity); + if (!std::isnan(d.wind)) + it.printf(234, 204, f.mono, TextAlign::BASELINE_RIGHT, "WND %.0f km/h", d.wind); + + // Right top: power. + cut_frame(it, 254, 26, 258, 118); + tag(it, f.mono, 254, 26, 14, "POWER GRID"); + struct Row { + const char *name; + float w; + }; + const Row rows[3] = {{"SOLAR", d.solar}, {"HOUSE", d.house}, {grid_word(d.grid), std::fabs(d.grid)}}; + for (int i = 0; i < 3; i++) { + const int y = 60 + i * 30; + it.print(264, y, f.label, TextAlign::BASELINE_LEFT, rows[i].name); + it.print(502, y, f.label, TextAlign::BASELINE_RIGHT, watts_text(rows[i].w).c_str()); + seg_bar(it, 264, y + 4, 240, 7, rows[i].w / 5000.0f); + } + + // Right bottom: zones. + 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()); + } + + // Bottom: ticker. + tag(it, f.mono, 0, 241, 14, ">>"); + const std::string feed = "CONNECTION STABLE // SOLAR " + watts_text(d.solar) + " // LOAD " + watts_text(d.house) + + " // OUTSIDE " + temp_text(d.temp, " C") + " // ICE NOMINAL // " + + hex_noise(t / 8000, 10) + " // "; + ticker(it, f.mono, 34, W, 253, t, feed); + + wipe_in(it, t); + glitch(it, t); +} + +// --- 2. Breach protocol: a code matrix that solves itself ------------------------ + +void breach_protocol(EL512Display &it, const Fonts &f) { + if (f.code == nullptr) + return; + const uint32_t t = it.page_age_ms(); + Puzzle p; + p.generate(breach_seed(it, t, 1), 6); + const BreachView v = breach_view(p, t % CYCLE); + + // Header and timer. + tag(it, f.title, 0, 0, 24, "BREACH PROTOCOL"); + it.print(420, 18, f.label, TextAlign::BASELINE_RIGHT, "BREACH TIME REMAINING"); + cut_frame(it, 428, 0, 84, 26, 6); + const bool low = v.remain < 10.0f && (t / 250) % 2; + if (!low) + it.printf(470, 13, f.code, TextAlign::CENTER, "%05.2f", v.remain); + it.filled_rectangle(0, 30, (int) (W * v.remain / TIME_LIMIT), 3); + + // 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); + + // Buffer. + it.print(304, 54, f.mono, TextAlign::BASELINE_LEFT, "BUFFER"); + draw_buffer(it, f.code, 304, 60, 30, 28, p, v.sel); + + // Daemons to upload. + it.print(304, 110, f.mono, TextAlign::BASELINE_LEFT, "SEQUENCES TO UPLOAD"); + for (int i = 0; i < DAEMON_COUNT; i++) { + const Daemon &dm = DAEMONS[i]; + const int y = 118 + i * 44; + const int got = daemon_progress(dm, v.sel); + for (int j = 0; j < dm.len; j++) { + const int x = 304 + j * 30; + const char *code = CODES[p.code(dm.start + j)]; + if (j < got) { + it.filled_rectangle(x, y, 26, 18); + it.print(x + 13, y + 9, f.mono, COLOR_OFF, TextAlign::CENTER, code); + } else { + it.rectangle(x, y, 26, 18); + it.print(x + 13, y + 9, f.mono, TextAlign::CENTER, code); + } + } + if (got == dm.len) { + tag(it, f.mono, 304, y + 22, 15, dm.name); + it.print(508, y + 13, f.mono, TextAlign::BASELINE_RIGHT, "INSTALLED"); + } else { + it.print(304, y + 34, f.label, TextAlign::BASELINE_LEFT, dm.name); + } + } + + // Result. + if (v.done()) { + const uint32_t r = v.local - INTRO - PATH * PICK; + if (r > 150 || (r / 50) % 2) { + it.filled_rectangle(16, 116, 264, 60, COLOR_OFF); + it.filled_rectangle(20, 120, 256, 52); + it.print(148, 140, f.title, COLOR_OFF, TextAlign::CENTER, "UPLOAD COMPLETE"); + it.printf(148, 162, f.mono, COLOR_OFF, TextAlign::CENTER, "%d/%d DAEMONS INSTALLED", DAEMON_COUNT, + DAEMON_COUNT); + } + } + + wipe_in(it, t); + glitch(it, t); +} + +// --- 3. Optics: boot log, calibration, then a scan of the house ------------------- + +void optics_scan(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now) { + if (f.huge == nullptr) + return; + const uint32_t t = it.page_age_ms(); + constexpr uint32_t BOOT = 350, CALIB = 3900, SCAN = 5000; + + if (t < BOOT) { + noise_blocks(it, t, 16); + glitch(it, t); + return; + } + + if (t < CALIB) { + // Boot log, typed at ~110 characters per second. + tag(it, f.title, 0, 0, 22, "OPTICS // BOOT"); + const std::string lines[] = { + "BIOS CHECKSUM ............. OK", + "NEURAL LINK HANDSHAKE ..... OK", + "ICE SUBSYSTEM ............. ARMED", + "OPTICAL ZOOM 1X-8X ........ OK", + "AMBIENT TEMP .............. " + temp_text(d.temp, " C"), + "SOLAR TAP ................. " + watts_text(d.solar), + "LOCAL TIME ................ " + (now.is_valid() ? now.strftime("%H:%M:%S") : std::string("SYNC")), + "> ALL SYSTEMS NOMINAL", + }; + size_t budget = (t - BOOT) * 110 / 1000; + int y = 46; + for (const auto &line : lines) { + const size_t n = std::min(budget, line.size()); + it.print(8, y, f.mono, TextAlign::BASELINE_LEFT, line.substr(0, n).c_str()); + if (n < line.size()) { + if ((t / 200) % 2) + it.filled_rectangle(8 + text_width(it, f.mono, line.substr(0, n).c_str()) + 1, y - 10, 7, 12); + break; + } + budget -= n; + y += 24; + } + // Load bars. + const char *bars[] = {"MEM", "CPU", "SYNC"}; + for (int i = 0; i < 3; i++) { + const float frac = std::clamp(((float) t - BOOT - i * 500) / (2600.0f - i * 300), 0.0f, 1.0f); + const int y0 = 60 + i * 56; + it.print(330, y0, f.label, TextAlign::BASELINE_LEFT, bars[i]); + it.printf(508, y0, f.value, TextAlign::BASELINE_RIGHT, "%d%%", (int) (frac * 100)); + seg_bar(it, 330, y0 + 8, 180, 10, frac); + } + it.vertical_line(316, 40, 200); + hatch(it, 330, 232, 180, 12, (int) (t / 60)); + glitch(it, t); + return; + } + + // Reticle: brackets shrink from the full screen onto the target. + const int cx = 140, cy = 134; + const int bx = 26, by = 24, bw = 228, bh = 224; + if (t < SCAN) { + const float k = (float) (t - CALIB) / (SCAN - CALIB); + const float e = 1 - (1 - k) * (1 - k); + brackets(it, (int) (bx * e), (int) (by * e), (int) (W + (bw - W) * e), (int) (H + (bh - H) * e), 24); + if ((t / 150) % 2) + it.print(256, 128, f.title, TextAlign::CENTER, "CALIBRATING"); + glitch(it, t); + return; + } + const uint32_t s = t - SCAN; + brackets(it, bx, by, bw, bh, 24); + tag(it, f.mono, bx, by - 18, 15, "TARGET LOCK"); + it.circle(cx, cy, 92); + it.circle(cx, cy, 64); + const int rot = (int) (s / 30) % 360; + for (int a = 0; a < 360; a += 30) + it.line_at_angle(cx, cy, a + rot, 86, 98); + for (int a = 0; a < 360; a += 90) + it.line_at_angle(cx, cy, a - rot / 2, 64, 72); + it.line_at_angle(cx, cy, (int) (s / 8) % 360, 0, 62); // radar sweep + it.horizontal_line(cx - 112, cy, 16); + it.horizontal_line(cx + 96, cy, 16); + it.vertical_line(cx, cy - 110, 14); + it.vertical_line(cx, cy + 96, 14); + if (now.is_valid()) { + it.filled_rectangle(cx - 52, cy - 18, 104, 30, COLOR_OFF); + it.strftime(cx, cy + 9, f.value, TextAlign::BASELINE_CENTER, "%H:%M", now); + it.strftime(cx, cy + 28, f.mono, TextAlign::BASELINE_CENTER, ":%S", now); + } + + // Scan results, typed in row by row. + cut_frame(it, 272, 24, 240, 214); + tag(it, f.mono, 272, 24, 15, "SCAN // HOME"); + const bool busy = !std::isnan(d.house) && d.house > 3000; + const std::string rows[][2] = { + {"CLASS", "RESIDENTIAL"}, + {"THREAT", busy ? "MODERATE" : "LOW"}, + {"TEMP", temp_text(d.temp, " C")}, + {"WEATHER", upper(weather_text(d.weather))}, + {"HUMIDITY", std::isnan(d.humidity) ? "--" : str("%.0f %%", d.humidity)}, + {"SOLAR", watts_text(d.solar)}, + {"LOAD", watts_text(d.house)}, + {"GRID", std::isnan(d.grid) ? "--" : (d.grid > 0 ? "+" : "-") + watts(std::fabs(d.grid))}, + }; + const int shown = std::min(8, (int) (s / 220)); + for (int i = 0; i < shown; i++) { + const int y = 62 + i * 20; + it.print(282, y, f.mono, TextAlign::BASELINE_LEFT, rows[i][0].c_str()); + it.print(376, y, f.mono, TextAlign::BASELINE_LEFT, el512::fit(f.mono, rows[i][1], 128).c_str()); + } + if (shown < 8 && (t / 120) % 2) + it.filled_rectangle(282, 52 + shown * 20, 7, 12); + if (shown == 8) { + hatch(it, 282, 220, 100, 10, (int) (t / 60)); + it.print(500, 230, f.mono, TextAlign::BASELINE_RIGHT, (s / 600) % 2 ? "SCAN COMPLETE" : ""); + } + glitch(it, t); +} + +// --- 4. Split: compact HUD on the left, a small breach on the right ---------------- + +void split_hud(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now) { + if (f.huge == nullptr) + return; + const uint32_t t = it.page_age_ms(); + + // Left: clock and four readouts. + clock_block(it, f, now, 270); + const std::string vals[4] = {temp_text(d.temp, "°C"), watts_text(d.solar), watts_text(d.house), + watts_text(std::fabs(d.grid))}; + const char *names[4] = {"OUTSIDE", "SOLAR", "LOAD", grid_word(d.grid)}; + for (int i = 0; i < 4; i++) { + const int x = 4 + (i % 2) * 140, y = 166 + (i / 2) * 46; + it.print(x, y, f.mono, TextAlign::BASELINE_LEFT, names[i]); + it.print(x, y + 28, f.value, TextAlign::BASELINE_LEFT, vals[i].c_str()); + } + // Divider with a notch. + it.vertical_line(284, 0, 110); + it.line(284, 110, 292, 118); + it.vertical_line(292, 118, 138); + + // Right: small breach. + Puzzle p; + p.generate(breach_seed(it, t, 2), 5); + const BreachView v = breach_view(p, t % CYCLE); + tag(it, f.mono, 300, 0, 15, "BREACH"); + it.printf(508, 12, f.mono, TextAlign::BASELINE_RIGHT, "%05.2f", v.remain); + it.filled_rectangle(300, 18, (int) (208 * v.remain / TIME_LIMIT), 2); + draw_matrix(it, {310, 36, 40, 30, f.mono}, p, v); + draw_buffer(it, f.mono, 304, 194, 30, 22, p, v.sel); + int installed = 0; + for (const auto &dm : DAEMONS) + installed += daemon_progress(dm, v.sel) == dm.len; + it.print(304, 246, f.mono, TextAlign::BASELINE_LEFT, v.done() ? "UPLOAD COMPLETE" : "DAEMONS"); + for (int i = 0; i < DAEMON_COUNT; i++) { + const int x = 470 + i * 14; + if (i < installed) + it.filled_rectangle(x, 236, 10, 10); + else + it.rectangle(x, 236, 10, 10); + } + + wipe_in(it, t); + glitch(it, t); +} + +} // namespace esphome::el512::cyber diff --git a/components/el512/cyberpunk.h b/components/el512/cyberpunk.h new file mode 100644 index 0000000..c05ac0b --- /dev/null +++ b/components/el512/cyberpunk.h @@ -0,0 +1,55 @@ +// Cyberpunk-style animated pages for the ESPHome display: a netrunner HUD, a +// self-solving breach protocol, an optics boot-and-scan sequence, and a HUD +// with a small breach matrix. Inspired by the look of Cyberpunk 2077; the +// layouts and graphics are drawn from scratch, no game assets are used. +// +// 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(), so every page starts its intro when it is shown. +#pragma once + +#include +#include + +#include "esphome/components/display/display.h" +#include "esphome/core/time.h" + +namespace esphome::el512 { + +class EL512Display; + +namespace cyber { + +struct Fonts { + display::BaseFont *title{nullptr}; // wide display face for headers (Orbitron) + display::BaseFont *label{nullptr}; // condensed labels (Rajdhani) + display::BaseFont *value{nullptr}; // condensed readouts (Rajdhani bold) + display::BaseFont *huge{nullptr}; // clock digits (Rajdhani bold) + display::BaseFont *mono{nullptr}; // small monospace for logs and tickers (Share Tech Mono) + display::BaseFont *code{nullptr}; // larger monospace for breach codes (Share Tech Mono) +}; + +struct Reading { + std::string name; + float value{NAN}; +}; + +// Home Assistant values shown on the pages; NAN or "" when unknown. +struct HomeData { + std::string weather; // condition, e.g. "partlycloudy" + float temp{NAN}; // °C + float humidity{NAN}; // % + float wind{NAN}; // km/h + float solar{NAN}; // W + float house{NAN}; // W + float grid{NAN}; // W, > 0 = import + Reading rooms[4]; +}; + +void netrunner_hud(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now); +void breach_protocol(EL512Display &it, const Fonts &f); +void optics_scan(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now); +void split_hud(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now); + +} // namespace cyber +} // namespace esphome::el512 diff --git a/components/el512/display.py b/components/el512/display.py index 545e6df..47da0ba 100644 --- a/components/el512/display.py +++ b/components/el512/display.py @@ -16,6 +16,8 @@ CONF_VS_PIN = "vs_pin" CONF_ENABLE_PIN = "enable_pin" CONF_LOW_POWER_PIN = "low_power_pin" CONF_STILL_ALIVE_PAGE = "still_alive_page" +CONF_ANIMATED_PAGES = "animated_pages" +CONF_ANIMATION_INTERVAL = "animation_interval" EL512Display = el512_ns.class_("EL512Display", display.DisplayBuffer) @@ -35,6 +37,12 @@ CONFIG_SCHEMA = cv.All( # Page (from `pages:`) that plays the Portal end-credits show; its # lambda should call it.draw_still_alive() via id(...). cv.Optional(CONF_STILL_ALIVE_PAGE): cv.use_id(display.DisplayPage), + # Pages that redraw every animation_interval while shown; their + # lambdas can use id(...).page_age_ms() as the animation clock. + cv.Optional(CONF_ANIMATED_PAGES): cv.ensure_list(cv.use_id(display.DisplayPage)), + cv.Optional( + CONF_ANIMATION_INTERVAL, default="66ms" + ): cv.positive_time_period_milliseconds, } ).extend(cv.polling_component_schema("1s")), cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA), @@ -59,6 +67,10 @@ async def to_code(config): if CONF_STILL_ALIVE_PAGE in config: page = await cg.get_variable(config[CONF_STILL_ALIVE_PAGE]) cg.add(var.set_still_alive_page(page)) + for page_id in config.get(CONF_ANIMATED_PAGES, []): + page = await cg.get_variable(page_id) + cg.add(var.add_animated_page(page)) + cg.add(var.set_animation_interval(config[CONF_ANIMATION_INTERVAL].total_milliseconds)) if CONF_LAMBDA in config: lambda_ = await cg.process_lambda( config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void diff --git a/components/el512/el512_display.cpp b/components/el512/el512_display.cpp index 73a58a6..73a0bb6 100644 --- a/components/el512/el512_display.cpp +++ b/components/el512/el512_display.cpp @@ -1,5 +1,8 @@ #include "el512_display.h" +#include +#include + #include #include @@ -26,10 +29,21 @@ void EL512Display::loop() { if (this->panel_ == nullptr || !this->panel_->running()) return; this->check_refresh_health_(); + const uint32_t now = millis(); + this->page_age_ms(); // notice page changes even when nothing draws + if (now - this->animated_at_ >= this->animation_interval_) { + const auto *page = this->get_active_page(); + for (const auto *p : this->animated_pages_) { + if (p == page) { + this->animated_at_ = now; + this->update(); + break; + } + } + } if (this->still_alive_page_ == nullptr) return; const bool active = this->is_still_alive_playing(); - const uint32_t now = millis(); if (active && !this->still_alive_was_active_) { // Remember where we came from, then start the show from the top. this->still_alive_return_ = this->previous_page_ != this->still_alive_page_ ? this->previous_page_ : nullptr; @@ -94,6 +108,46 @@ void EL512Display::draw_still_alive() { this->still_alive_.draw(*this->panel_); } +uint32_t EL512Display::page_age_ms() { + const uint32_t now = millis(); + if (this->get_active_page() != this->age_page_) { + this->age_page_ = this->get_active_page(); + this->age_start_ = now; + } + return now - this->age_start_; +} + +void EL512Display::shift_rows(int y, int height, int dx) { + if (this->panel_ == nullptr) + return; + constexpr int W = EL512Panel::WIDTH, ROW = W / 8; + dx %= W; + if (dx < 0) + dx += W; + if (dx == 0) + return; + uint8_t *fb = this->panel_->framebuffer(); + uint8_t src[ROW]; + for (int r = std::max(y, 0); r < std::min(y + height, (int) EL512Panel::HEIGHT); r++) { + uint8_t *row = fb + r * ROW; + memcpy(src, row, ROW); + memset(row, 0, ROW); + for (int x = 0; x < W; x++) { + const int s = (x - dx + W) % W; + if (src[s >> 3] & (0x80 >> (s & 7))) + row[x >> 3] |= 0x80 >> (x & 7); + } + } +} + +void EL512Display::invert_rect(int x, int y, int width, int height) { + if (this->panel_ == nullptr) + return; + for (int py = y; py < y + height; py++) + for (int px = x; px < x + width; px++) + this->panel_->setPixel(px, py, !this->panel_->getPixel(px, py)); +} + void EL512Display::update() { if (this->panel_ == nullptr || !this->panel_->running()) return; diff --git a/components/el512/el512_display.h b/components/el512/el512_display.h index 1369b3f..f8b9494 100644 --- a/components/el512/el512_display.h +++ b/components/el512/el512_display.h @@ -1,5 +1,8 @@ #pragma once +#include + +#include "cyberpunk.h" #include "el512_helpers.h" #include "el512_panel.h" #include "esphome/components/display/display_buffer.h" @@ -49,6 +52,21 @@ class EL512Display : public display::DisplayBuffer { // When to start the music, in ms after the page was shown. uint32_t still_alive_song_start_ms() const { return el512sa::StillAlivePlayer::songStartMs(); } + // Animated pages redraw every animation interval while they are shown, + // instead of once per update_interval. Their lambdas can use page_age_ms() + // as the animation clock. + void add_animated_page(display::DisplayPage *page) { this->animated_pages_.push_back(page); } + void set_animation_interval(uint32_t ms) { this->animation_interval_ = ms; } + // Time since the active page was shown, in ms. + uint32_t page_age_ms(); + // millis() when the active page was shown (as seen by page_age_ms()). + uint32_t page_shown_at() const { return this->age_start_; } + + // Effects on the drawing buffer, for use after drawing a page: shift rows + // y..y+height-1 right by dx pixels (wrapping around), invert a rectangle. + void shift_rows(int y, int height, int dx); + void invert_rect(int x, int y, int width, int height); + protected: void draw_absolute_pixel_internal(int x, int y, Color color) override; void check_refresh_health_(); @@ -66,6 +84,12 @@ class EL512Display : public display::DisplayBuffer { display::DisplayPage *still_alive_return_{nullptr}; bool still_alive_was_active_{false}; el512sa::StillAlivePlayer still_alive_; + + std::vector animated_pages_; + uint32_t animation_interval_{66}; + uint32_t animated_at_{0}; + const display::DisplayPage *age_page_{nullptr}; + uint32_t age_start_{0}; }; } // namespace esphome::el512 diff --git a/esphome/el512-panel.yaml b/esphome/el512-panel.yaml index 563e658..3b1fd5f 100644 --- a/esphome/el512-panel.yaml +++ b/esphome/el512-panel.yaml @@ -14,6 +14,9 @@ substitutions: esphome: name: ${name} friendly_name: ${friendly_name} + on_boot: + - lambda: |- + id(cp_fonts) = {id(f_cp_title), id(f_cp_label), id(f_cp_value), id(f_cp_huge), id(f_cp_mono), id(f_cp_code)}; esp32: board: wemos_d1_mini32 @@ -56,6 +59,11 @@ globals: - id: np_updated type: uint32_t initial_value: "0" + # Cyberpunk pages: their fonts and the home data they show. + - id: cp_fonts + type: el512::cyber::Fonts + - id: cp_home + type: el512::cyber::HomeData # --- Fonts ------------------------------------------------------------------- font: @@ -126,6 +134,31 @@ font: size: 18 glyphs: - "\U000F054B" # umbrella-outline + # Cyberpunk pages + - file: "gfonts://Orbitron@700" + id: f_cp_title + size: 18 + glyphs: " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/_-.:>" + - file: "gfonts://Rajdhani@600" + id: f_cp_label + size: 18 + glyphsets: [GF_Latin_Core] + - file: "gfonts://Rajdhani@700" + id: f_cp_value + size: 32 + glyphs: " 0123456789.,:-+%°CkWh" + - file: "gfonts://Rajdhani@700" + id: f_cp_huge + size: 104 + glyphs: "0123456789:" + - file: "gfonts://Share Tech Mono" + id: f_cp_mono + size: 14 + glyphsets: [GF_Latin_Core] + - file: "gfonts://Share Tech Mono" + id: f_cp_code + size: 22 + glyphs: " 0123456789ABCDEF." # --- Data from Home Assistant ------------------------------------------------- text_sensor: @@ -313,6 +346,10 @@ select: - Plex - Solar - Still Alive + - Cyber HUD + - Cyber Breach + - Cyber Scan + - Cyber Split initial_option: Clock set_action: - lambda: |- @@ -325,6 +362,10 @@ select: else if (x == "Now playing") p = id(page_media); else if (x == "Plex") p = id(page_plex); else if (x == "Solar") p = id(page_solar); + else if (x == "Cyber HUD") p = id(page_cyber_hud); + else if (x == "Cyber Breach") p = id(page_cyber_breach); + else if (x == "Cyber Scan") p = id(page_cyber_scan); + else if (x == "Cyber Split") p = id(page_cyber_split); id(panel).show_page(p); id(panel).update(); @@ -345,7 +386,7 @@ interval: if (id(sw_pause).state || id(panel).is_still_alive_playing()) { id(rotation_elapsed) = 0; return; } if (++id(rotation_elapsed) < (int) id(page_interval).state) return; id(rotation_elapsed) = 0; - for (int i = 0; i < 8; i++) { + for (int i = 0; i < 12; i++) { id(panel).show_next_page(); const auto *p = id(panel).get_active_page(); const std::string &np = id(np_state).state; @@ -354,6 +395,22 @@ interval: break; } id(panel).update(); + # Home data for the Cyberpunk pages. + - interval: 1s + then: + - lambda: |- + auto &d = id(cp_home); + d.weather = id(weather_cond).state; + d.temp = id(weather_temp).state; + d.humidity = id(weather_hum).state; + d.wind = id(weather_wind).state; + d.solar = id(solar_power).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}; + d.rooms[1] = {"Voorkamer", id(t_voorkamer).state}; + d.rooms[2] = {"Patrick", id(t_patrick).state}; + d.rooms[3] = {"Badkamer", id(t_badkamer).state}; # --- The panel and its pages ---------------------------------------------------- display: @@ -363,6 +420,9 @@ display: low_power_pin: GPIO26 update_interval: 1s still_alive_page: page_still_alive + # Redrawn every animation_interval (~15 fps) while shown. + animated_pages: [page_cyber_hud, page_cyber_breach, page_cyber_scan, page_cyber_split] + animation_interval: 66ms on_page_change: - then: - lambda: |- @@ -374,6 +434,10 @@ display: else if (to == id(page_plex)) name = "Plex"; else if (to == id(page_solar)) name = "Solar"; else if (to == id(page_still_alive)) name = "Still Alive"; + else if (to == id(page_cyber_hud)) name = "Cyber HUD"; + else if (to == id(page_cyber_breach)) name = "Cyber Breach"; + else if (to == id(page_cyber_scan)) name = "Cyber Scan"; + else if (to == id(page_cyber_split)) name = "Cyber Split"; if (id(page_select).current_option() != name) id(page_select).publish_state(name); pages: # 1. Clock, date and current weather @@ -555,3 +619,17 @@ display: - id: page_still_alive lambda: |- id(panel).draw_still_alive(); + + # 9-12. Cyberpunk-style animated pages (components/el512/cyberpunk.cpp) + - id: page_cyber_hud + lambda: |- + el512::cyber::netrunner_hud(*id(panel), id(cp_fonts), id(cp_home), id(ha_time).now()); + - id: page_cyber_breach + lambda: |- + el512::cyber::breach_protocol(*id(panel), id(cp_fonts)); + - id: page_cyber_scan + lambda: |- + el512::cyber::optics_scan(*id(panel), id(cp_fonts), id(cp_home), id(ha_time).now()); + - id: page_cyber_split + lambda: |- + el512::cyber::split_hud(*id(panel), id(cp_fonts), id(cp_home), id(ha_time).now());