Public Access
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>
This commit is contained in:
1 parent
410885de24
commit
963590cdfe
27 files changed
+2110
-828
No files matched your search
@@ -0,0 +1,252 @@
|
||||
#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
|
||||
Reference in new issue
Block a user