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 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-18 23:29:12 +02:00
1 parent 6d5e5315f0
commit 50ee17b3f6
7 files changed
+944 -4

No files matched your search

+32 -2
View File
@@ -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) el512_panel.* framebuffer, DMA ring, refresh interrupt (ESP-IDF API)
still_alive.* Portal end-credits show, 5x7 font (el512_font.h) still_alive.* Portal end-credits show, 5x7 font (el512_font.h)
still_alive_data.h show content, generated by tools/import_still_alive.py 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` el512_display.* ESPHome display platform `el512`
__init__.py, display.py ESPHome config schema __init__.py, display.py ESPHome config schema
lib/EL512/ Arduino library: Adafruit_GFX front end (EL512_GFX) lib/EL512/ Arduino library: Adafruit_GFX front end (EL512_GFX)
@@ -108,8 +109,9 @@ pio device monitor
## ESPHome / Home Assistant ## ESPHome / Home Assistant
[esphome/el512-panel.yaml](esphome/el512-panel.yaml) turns the panel into a Home [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 Assistant display with twelve pages: clock and weather, forecast, agenda, a sensor
dashboard with a message line, now playing, Plex, solar, and Still Alive. Pages 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 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 interval, a message text, a "Play Still Alive" button, and Display / Low power mode
switches (ENABLE, _LOWPWR). switches (ENABLE, _LOWPWR).
@@ -130,6 +132,8 @@ display:
enable_pin: GPIO25 # optional, J2-6 enable_pin: GPIO25 # optional, J2-6
low_power_pin: GPIO26 # optional, J2-5 low_power_pin: GPIO26 # optional, J2-5
still_alive_page: page_still_alive # optional 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: ... 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 all rights remain with their owners. This project is not affiliated with or
endorsed by Valve. 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 ## Using the driver
```cpp ```cpp
+687
View File
@@ -0,0 +1,687 @@
#include "cyberpunk.h"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdarg>
#include <cstdio>
#include <cstdlib>
#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<uint32_t>(u, PATH * PICK);
if (solve > SELECT_AT)
v.remain = std::max(0.0f, TIME_LIMIT - (solve - SELECT_AT) / 1000.0f);
v.pick = std::min<int>(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<int>(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
+55
View File
@@ -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 <cmath>
#include <string>
#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
+12
View File
@@ -16,6 +16,8 @@ CONF_VS_PIN = "vs_pin"
CONF_ENABLE_PIN = "enable_pin" CONF_ENABLE_PIN = "enable_pin"
CONF_LOW_POWER_PIN = "low_power_pin" CONF_LOW_POWER_PIN = "low_power_pin"
CONF_STILL_ALIVE_PAGE = "still_alive_page" CONF_STILL_ALIVE_PAGE = "still_alive_page"
CONF_ANIMATED_PAGES = "animated_pages"
CONF_ANIMATION_INTERVAL = "animation_interval"
EL512Display = el512_ns.class_("EL512Display", display.DisplayBuffer) 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 # Page (from `pages:`) that plays the Portal end-credits show; its
# lambda should call it.draw_still_alive() via id(...). # lambda should call it.draw_still_alive() via id(...).
cv.Optional(CONF_STILL_ALIVE_PAGE): cv.use_id(display.DisplayPage), 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")), ).extend(cv.polling_component_schema("1s")),
cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA), 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: if CONF_STILL_ALIVE_PAGE in config:
page = await cg.get_variable(config[CONF_STILL_ALIVE_PAGE]) page = await cg.get_variable(config[CONF_STILL_ALIVE_PAGE])
cg.add(var.set_still_alive_page(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: if CONF_LAMBDA in config:
lambda_ = await cg.process_lambda( lambda_ = await cg.process_lambda(
config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void
+55 -1
View File
@@ -1,5 +1,8 @@
#include "el512_display.h" #include "el512_display.h"
#include <algorithm>
#include <cstring>
#include <esp_heap_caps.h> #include <esp_heap_caps.h>
#include <esp_private/esp_clk.h> #include <esp_private/esp_clk.h>
@@ -26,10 +29,21 @@ void EL512Display::loop() {
if (this->panel_ == nullptr || !this->panel_->running()) if (this->panel_ == nullptr || !this->panel_->running())
return; return;
this->check_refresh_health_(); 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) if (this->still_alive_page_ == nullptr)
return; return;
const bool active = this->is_still_alive_playing(); const bool active = this->is_still_alive_playing();
const uint32_t now = millis();
if (active && !this->still_alive_was_active_) { if (active && !this->still_alive_was_active_) {
// Remember where we came from, then start the show from the top. // 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; 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_); 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() { void EL512Display::update() {
if (this->panel_ == nullptr || !this->panel_->running()) if (this->panel_ == nullptr || !this->panel_->running())
return; return;
+24
View File
@@ -1,5 +1,8 @@
#pragma once #pragma once
#include <vector>
#include "cyberpunk.h"
#include "el512_helpers.h" #include "el512_helpers.h"
#include "el512_panel.h" #include "el512_panel.h"
#include "esphome/components/display/display_buffer.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. // When to start the music, in ms after the page was shown.
uint32_t still_alive_song_start_ms() const { return el512sa::StillAlivePlayer::songStartMs(); } 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: protected:
void draw_absolute_pixel_internal(int x, int y, Color color) override; void draw_absolute_pixel_internal(int x, int y, Color color) override;
void check_refresh_health_(); void check_refresh_health_();
@@ -66,6 +84,12 @@ class EL512Display : public display::DisplayBuffer {
display::DisplayPage *still_alive_return_{nullptr}; display::DisplayPage *still_alive_return_{nullptr};
bool still_alive_was_active_{false}; bool still_alive_was_active_{false};
el512sa::StillAlivePlayer still_alive_; el512sa::StillAlivePlayer still_alive_;
std::vector<display::DisplayPage *> 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 } // namespace esphome::el512
+79 -1
View File
@@ -14,6 +14,9 @@ substitutions:
esphome: esphome:
name: ${name} name: ${name}
friendly_name: ${friendly_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: esp32:
board: wemos_d1_mini32 board: wemos_d1_mini32
@@ -56,6 +59,11 @@ globals:
- id: np_updated - id: np_updated
type: uint32_t type: uint32_t
initial_value: "0" 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 ------------------------------------------------------------------- # --- Fonts -------------------------------------------------------------------
font: font:
@@ -126,6 +134,31 @@ font:
size: 18 size: 18
glyphs: glyphs:
- "\U000F054B" # umbrella-outline - "\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 ------------------------------------------------- # --- Data from Home Assistant -------------------------------------------------
text_sensor: text_sensor:
@@ -313,6 +346,10 @@ select:
- Plex - Plex
- Solar - Solar
- Still Alive - Still Alive
- Cyber HUD
- Cyber Breach
- Cyber Scan
- Cyber Split
initial_option: Clock initial_option: Clock
set_action: set_action:
- lambda: |- - lambda: |-
@@ -325,6 +362,10 @@ select:
else if (x == "Now playing") p = id(page_media); else if (x == "Now playing") p = id(page_media);
else if (x == "Plex") p = id(page_plex); else if (x == "Plex") p = id(page_plex);
else if (x == "Solar") p = id(page_solar); 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).show_page(p);
id(panel).update(); 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(sw_pause).state || id(panel).is_still_alive_playing()) { id(rotation_elapsed) = 0; return; }
if (++id(rotation_elapsed) < (int) id(page_interval).state) return; if (++id(rotation_elapsed) < (int) id(page_interval).state) return;
id(rotation_elapsed) = 0; id(rotation_elapsed) = 0;
for (int i = 0; i < 8; i++) { for (int i = 0; i < 12; i++) {
id(panel).show_next_page(); id(panel).show_next_page();
const auto *p = id(panel).get_active_page(); const auto *p = id(panel).get_active_page();
const std::string &np = id(np_state).state; const std::string &np = id(np_state).state;
@@ -354,6 +395,22 @@ interval:
break; break;
} }
id(panel).update(); 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 ---------------------------------------------------- # --- The panel and its pages ----------------------------------------------------
display: display:
@@ -363,6 +420,9 @@ display:
low_power_pin: GPIO26 low_power_pin: GPIO26
update_interval: 1s update_interval: 1s
still_alive_page: page_still_alive 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: on_page_change:
- then: - then:
- lambda: |- - lambda: |-
@@ -374,6 +434,10 @@ display:
else if (to == id(page_plex)) name = "Plex"; else if (to == id(page_plex)) name = "Plex";
else if (to == id(page_solar)) name = "Solar"; else if (to == id(page_solar)) name = "Solar";
else if (to == id(page_still_alive)) name = "Still Alive"; 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); if (id(page_select).current_option() != name) id(page_select).publish_state(name);
pages: pages:
# 1. Clock, date and current weather # 1. Clock, date and current weather
@@ -555,3 +619,17 @@ display:
- id: page_still_alive - id: page_still_alive
lambda: |- lambda: |-
id(panel).draw_still_alive(); 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());