Files
planar-EL512.256-H/components/el512/still_alive.cpp
T
mangavalkandClaude Opus 5.5 963590cdfe Add ESPHome display component; ring-buffer driver shared with PlatformIO
Driver (components/el512/el512_panel.*, ESP-IDF only):
- DMA ring of 8 x 4 lines (17 KB) refilled from the I2S EOF interrupt
  (IRAM) instead of a 142 KB full-frame stream; ~107 KB heap freed.
- 256-entry lookup table encodes 8 pixels per step.
- Back buffer + displayed buffer, swapped at the start of a frame.
- PlatformIO builds it via lib_extra_dirs (EL512Core); lib/EL512 keeps
  the Adafruit GFX front end.

ESPHome external component (platform: el512):
- DisplayBuffer with optional ENABLE / _LOWPWR pins, set_enabled(),
  set_low_power(), helper functions for page lambdas.
- still_alive_page: plays the Portal end-credits show, then returns to
  the previous page.

Still Alive engine moved to components/el512/still_alive.* with a built-in
5x7 font (from Adafruit GFX, BSD); the PlatformIO demo uses the same code.
The importer writes components/el512/still_alive_data.h and stops at 200 s
(--until-ms), matching the hand-trimmed data.

esphome/el512-panel.yaml: 8 pages (clock, forecast, agenda, dashboard,
now playing, Plex, solar, Still Alive), page select, rotation, display
and low-power switches, message text. home_assistant/packages/el512_panel.yaml:
helper template sensors for forecast, agenda, now playing and Plex streams.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:20:13 +02:00

253 lines
7.0 KiB
C++

#include "still_alive.h"
#include <string.h>
#include "el512_font.h"
#include "el512_panel.h"
#include "still_alive_data.h"
namespace el512sa {
namespace {
uint8_t fit(int16_t px, int16_t cell, uint8_t cap) {
const int n = (px - 2 * TextBox::PAD) / cell;
return n > cap ? cap : (uint8_t) n;
}
bool due(uint32_t now, uint32_t at) { return (int32_t) (now - at) >= 0; }
void drawChar(EL512Panel &panel, int16_t x, int16_t y, char c) {
if (c < EL512_FONT_FIRST || c > EL512_FONT_LAST)
c = '?';
const uint8_t *glyph = &EL512_FONT[(c - EL512_FONT_FIRST) * 5];
for (int16_t col = 0; col < 5; col++) {
const uint8_t bits = glyph[col];
for (int16_t row = 0; row < 8; row++) {
if (bits & (1 << row))
panel.setPixel(x + col, y + row, true);
}
}
}
} // namespace
// --- TextBox -----------------------------------------------------------------
TextBox::TextBox(int16_t x, int16_t y, int16_t w, int16_t h)
: x_(x), y_(y), w_(w), h_(h), cols_(fit(w, CHAR_W, MAX_COLS)), rows_(fit(h, CHAR_H, MAX_ROWS)) {
clear();
}
void TextBox::clear() {
memset(text_, 0, sizeof(text_));
row_ = 0;
col_ = 0;
}
void TextBox::put(char c) {
if (c == '\n') {
newLine();
return;
}
if (col_ >= cols_)
newLine();
text_[row_][col_++] = c;
}
void TextBox::print(const char *s) {
while (*s)
put(*s++);
}
void TextBox::newLine() {
col_ = 0;
if (row_ + 1 < rows_) {
row_++;
return;
}
memmove(text_[0], text_[1], (size_t) (rows_ - 1) * sizeof(text_[0]));
memset(text_[rows_ - 1], 0, sizeof(text_[0]));
}
void TextBox::draw(EL512Panel &panel, bool cursor, bool frame) const {
if (frame) {
// Dashed outline, 4 px on / 2 px off.
for (int16_t i = 0; i < w_; i++) {
if (i % 6 < 4) {
panel.setPixel(x_ + i, y_, true);
panel.setPixel(x_ + i, y_ + h_ - 1, true);
}
}
for (int16_t i = 0; i < h_; i++) {
if (i % 6 < 4) {
panel.setPixel(x_, y_ + i, true);
panel.setPixel(x_ + w_ - 1, y_ + i, true);
}
}
}
for (uint8_t r = 0; r < rows_; r++) {
for (uint8_t c = 0; c < cols_ && text_[r][c]; c++) {
if (text_[r][c] != ' ')
drawChar(panel, x_ + PAD + c * CHAR_W, y_ + PAD + r * CHAR_H, text_[r][c]);
}
}
if (cursor) {
for (int16_t dy = 6; dy < 8; dy++)
for (int16_t dx = 0; dx < 5; dx++)
panel.setPixel(x_ + PAD + col_ * CHAR_W + dx, y_ + PAD + row_ * CHAR_H + dy, true);
}
}
// --- StillAlivePlayer ----------------------------------------------------------
// 41 x 31 lyrics console | 41 x 9 credits over a 41 x 20 picture.
StillAlivePlayer::StillAlivePlayer()
: lyrics_(0, 0, 254, 256), credits_(256, 0, 256, 86), art_(256, 88, 256, 168) {}
uint32_t StillAlivePlayer::songStartMs() { return START_DELAY_MS + SONG_START_MS; }
void StillAlivePlayer::start(uint32_t nowMs) {
lyrics_.clear();
credits_.clear();
showArt(ART_NONE);
showStart_ = nowMs + START_DELAY_MS;
step_ = 0;
charIdx_ = 0;
stepStarted_ = false;
done_ = false;
doneAt_ = 0;
creditIdx_ = 0;
creditChar_ = 0;
creditLineOpen_ = false;
creditsDone_ = false;
creditLineAt_ = CREDITS_START_MS;
// Credits are typed on the bottom row and scroll up, as in the game.
for (uint8_t r = 1; r < credits_.rows(); r++)
credits_.put('\n');
}
bool StillAlivePlayer::finished(uint32_t nowMs) const { return done_ && due(nowMs, doneAt_ + HOLD_MS); }
void StillAlivePlayer::showArt(int8_t id) {
art_.clear();
if (id <= ART_NONE || (size_t) id >= ART_COUNT)
return;
const Art &art = ARTS[id];
size_t width = 0;
for (uint8_t i = 0; i < art.count; i++) {
const size_t len = strlen(art.lines[i]);
if (len > width)
width = len;
}
const uint8_t top = art.count < art_.rows() ? (art_.rows() - art.count) / 2 : 0;
const uint8_t left = width < art_.cols() ? (art_.cols() - width) / 2 : 0;
for (uint8_t r = 0; r < top; r++)
art_.put('\n');
for (uint8_t i = 0; i < art.count && top + i < art_.rows(); i++) {
if (i > 0)
art_.put('\n');
for (uint8_t c = 0; c < left; c++)
art_.put(' ');
// Truncate instead of wrapping so a wide picture keeps its shape.
for (const char *p = art.lines[i]; *p && left + (p - art.lines[i]) < art_.cols(); p++)
art_.put(*p);
}
}
bool StillAlivePlayer::update(uint32_t nowMs) {
bool changed = false;
if (due(nowMs, showStart_)) {
const uint32_t t = nowMs - showStart_;
changed |= updateLyrics(t, nowMs);
changed |= updateCredits(t);
}
const bool blink = (nowMs / CURSOR_BLINK_MS) & 1;
const bool creditsBlink = blink && !creditsDone_ && due(nowMs, showStart_ + CREDITS_START_MS);
changed |= blink != cursor_ || creditsBlink != creditsCursor_;
cursor_ = blink;
creditsCursor_ = creditsBlink;
return changed;
}
// Type whatever the clock says should be on screen by now.
bool StillAlivePlayer::updateLyrics(uint32_t t, uint32_t nowMs) {
bool changed = false;
while (step_ < LYRIC_COUNT && t >= LYRICS[step_].startMs) {
const LyricStep &s = LYRICS[step_];
if (!stepStarted_) {
stepStarted_ = true;
if (s.flags & STEP_CLEAR)
lyrics_.clear();
if (s.art != ART_KEEP)
showArt(s.art);
changed = true;
}
const size_t len = strlen(s.text);
const uint32_t elapsed = t - s.startMs;
const size_t want = (s.durMs == 0 || elapsed >= s.durMs) ? len : len * elapsed / s.durMs;
while (charIdx_ < want) {
lyrics_.put(s.text[charIdx_++]);
changed = true;
}
if (charIdx_ < len)
break; // still typing this step
if (s.flags & STEP_NEWLINE)
lyrics_.put('\n');
step_++;
charIdx_ = 0;
stepStarted_ = false;
changed = true;
if (step_ == LYRIC_COUNT) {
done_ = true;
doneAt_ = nowMs;
}
}
return changed;
}
// Each credits line takes CREDIT_CHAR_US per character (an empty line counts as
// one), typed evenly; then the next line starts on a fresh bottom row.
bool StillAlivePlayer::updateCredits(uint32_t t) {
if (creditsDone_ || t < CREDITS_START_MS)
return false;
bool changed = false;
while (true) {
const char *line = CREDITS[creditIdx_];
const size_t len = strlen(line);
if (!creditLineOpen_) {
creditLineOpen_ = true;
credits_.put('\n');
changed = true;
}
const uint32_t dur = (uint32_t) ((uint64_t) CREDIT_CHAR_US * (len ? len : 1) / 1000);
const uint32_t elapsed = t - creditLineAt_;
const size_t want = elapsed >= dur ? len : len * elapsed / dur;
while (creditChar_ < want) {
credits_.put(line[creditChar_++]);
changed = true;
}
if (elapsed < dur)
break;
creditLineAt_ += dur;
creditChar_ = 0;
creditLineOpen_ = false;
if (++creditIdx_ == CREDIT_COUNT) {
creditIdx_ = 0;
if (!CREDITS_LOOP) {
creditsDone_ = true;
break;
}
}
}
return changed;
}
void StillAlivePlayer::draw(EL512Panel &panel) const {
lyrics_.draw(panel, cursor_);
credits_.draw(panel, creditsCursor_);
art_.draw(panel, false, false); // no frame around the picture, as in the game
}
} // namespace el512sa