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:
mangavalkandClaude Opus 5.5 committed 2026-10-18 20:20:13 +02:00
1 parent 410885de24
commit 963590cdfe
27 files changed
+2110 -828

No files matched your search

+13
View File
@@ -24,3 +24,16 @@ $RECYCLE.BIN/
# Editors # Editors
*.swp *.swp
*~ *~
# Python environment for the ESPHome CLI
.venv/
# ESPHome build output and local secrets
.esphome/
esphome/secrets.yaml
# Local test copy of el512-panel.yaml that uses ../components instead of git
esphome/*-local.yaml
# Python bytecode (ESPHome component)
__pycache__/
+10
View File
@@ -0,0 +1,10 @@
"""Planar EL512.256-H electroluminescent panel (512x256) on an ESP32.
Driven by I2S0 in parallel mode with DMA; see el512_panel.h for the details.
"""
import esphome.codegen as cg
CODEOWNERS = ["@mangavalk"]
el512_ns = cg.esphome_ns.namespace("el512")
+66
View File
@@ -0,0 +1,66 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import display
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_LAMBDA, CONF_PAGES
from . import el512_ns
DEPENDENCIES = ["esp32"]
CONF_VCLK_PIN = "vclk_pin"
CONF_VID_PIN = "vid_pin"
CONF_TVID_PIN = "tvid_pin"
CONF_HS_PIN = "hs_pin"
CONF_VS_PIN = "vs_pin"
CONF_ENABLE_PIN = "enable_pin"
CONF_LOW_POWER_PIN = "low_power_pin"
CONF_STILL_ALIVE_PAGE = "still_alive_page"
EL512Display = el512_ns.class_("EL512Display", display.DisplayBuffer)
# The signals are routed through the GPIO matrix, so any output-capable GPIO
# works; the defaults are the wiring in include/pins.h.
CONFIG_SCHEMA = cv.All(
display.FULL_DISPLAY_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(EL512Display),
cv.Optional(CONF_VCLK_PIN, default=18): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_VID_PIN, default=19): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_TVID_PIN, default=21): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_HS_PIN, default=22): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_VS_PIN, default=23): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_ENABLE_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_LOW_POWER_PIN): pins.internal_gpio_output_pin_number,
# 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),
}
).extend(cv.polling_component_schema("1s")),
cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await display.register_display(var, config)
cg.add(
var.set_pins(
config[CONF_VCLK_PIN],
config[CONF_VID_PIN],
config[CONF_TVID_PIN],
config[CONF_HS_PIN],
config[CONF_VS_PIN],
config.get(CONF_ENABLE_PIN, -1),
config.get(CONF_LOW_POWER_PIN, -1),
)
)
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))
if CONF_LAMBDA in config:
lambda_ = await cg.process_lambda(
config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void
)
cg.add(var.set_writer(lambda_))
+110
View File
@@ -0,0 +1,110 @@
#include "el512_display.h"
#include <esp_heap_caps.h>
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome::el512 {
static const char *const TAG = "el512";
void EL512Display::setup() {
this->panel_ = new EL512Panel(this->pins_); // NOLINT(cppcoreguidelines-owning-memory)
if (!this->panel_->begin()) {
ESP_LOGE(TAG, "Panel init failed (out of DMA memory or no free interrupt)");
this->mark_failed();
return;
}
// Apply states that switches may have restored before setup ran.
this->panel_->setLowPower(this->low_power_);
this->panel_->setEnabled(this->enabled_);
}
void EL512Display::loop() {
if (this->still_alive_page_ == nullptr || this->panel_ == nullptr || !this->panel_->running())
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;
this->still_alive_.start(now);
ESP_LOGI(TAG, "Still Alive started; start the song in %u ms", (unsigned) this->still_alive_song_start_ms());
}
this->still_alive_was_active_ = active;
if (!active)
return;
if (this->still_alive_.finished(now) && this->still_alive_return_ != nullptr) {
this->show_page(this->still_alive_return_);
this->update();
return;
}
if (this->still_alive_.update(now))
this->update();
}
void EL512Display::play_still_alive() {
if (this->still_alive_page_ == nullptr)
return;
if (this->is_still_alive_playing()) {
this->still_alive_.start(millis()); // already showing: just restart
} else {
this->show_page(this->still_alive_page_); // loop() starts it
}
}
void EL512Display::draw_still_alive() {
if (this->panel_ != nullptr)
this->still_alive_.draw(*this->panel_);
}
void EL512Display::update() {
if (this->panel_ == nullptr || !this->panel_->running())
return;
this->do_update_();
this->panel_->show();
}
void EL512Display::fill(Color color) {
// The panel clear is a memset; with clipping active, fall back to per-pixel.
if (this->panel_ == nullptr || this->get_clipping().is_set()) {
Display::fill(color);
return;
}
this->panel_->clear(color.is_on());
}
void EL512Display::draw_absolute_pixel_internal(int x, int y, Color color) {
if (this->panel_ != nullptr)
this->panel_->setPixel(x, y, color.is_on());
}
void EL512Display::set_enabled(bool on) {
this->enabled_ = on;
if (this->panel_ != nullptr)
this->panel_->setEnabled(on);
}
void EL512Display::set_low_power(bool on) {
this->low_power_ = on;
if (this->panel_ != nullptr)
this->panel_->setLowPower(on);
}
void EL512Display::dump_config() {
LOG_DISPLAY("", "EL512.256-H", this);
ESP_LOGCONFIG(TAG,
" Pins: VCLK %d, VID %d, TVID %d, HS %d, VS %d, ENABLE %d, _LOWPWR %d\n"
" Refresh: %.1f Hz, DMA ring %u bytes\n"
" Free internal heap: %u bytes",
this->pins_.vclk, this->pins_.vid, this->pins_.tvid, this->pins_.hs, this->pins_.vs,
this->pins_.enable, this->pins_.lowpwr_n, EL512Panel::refreshHz(),
(unsigned) EL512Panel::RING_BYTES,
(unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
LOG_UPDATE_INTERVAL(this);
if (this->is_failed())
ESP_LOGE(TAG, " Init failed");
}
} // namespace esphome::el512
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include "el512_helpers.h"
#include "el512_panel.h"
#include "esphome/components/display/display_buffer.h"
#include "esphome/core/component.h"
#include "still_alive.h"
namespace esphome::el512 {
// ESPHome display for the Planar EL512.256-H. Drawing goes into the panel
// driver's back buffer; every update() hands it to the refresh in one piece.
class EL512Display : public display::DisplayBuffer {
public:
void set_pins(int vclk, int vid, int tvid, int hs, int vs, int enable, int low_power) {
this->pins_ = {(int8_t) vclk, (int8_t) vid, (int8_t) tvid, (int8_t) hs,
(int8_t) vs, (int8_t) enable, (int8_t) low_power};
}
void setup() override;
void loop() override;
void update() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::PROCESSOR; }
void fill(Color color) override;
display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_BINARY; }
// Display on/off (ENABLE) and low-power mode (_LOWPWR), e.g. from template switches.
void set_enabled(bool on);
void set_low_power(bool on);
bool is_enabled() const { return this->enabled_; }
bool is_low_power() const { return this->low_power_; }
bool has_enable_pin() const { return this->pins_.enable >= 0; }
bool has_low_power_pin() const { return this->pins_.lowpwr_n >= 0; }
// Portal end-credits show. Showing this page restarts it; while it is the
// active page the display redraws as the show advances, and when it ends the
// previous page comes back. Draw it from that page's lambda with
// draw_still_alive().
void set_still_alive_page(display::DisplayPage *page) { this->still_alive_page_ = page; }
void draw_still_alive();
// Show the Still Alive page and start the show from the top (also when it is
// already playing).
void play_still_alive();
bool is_still_alive_playing() const {
return this->still_alive_page_ != nullptr && this->get_active_page() == this->still_alive_page_;
}
// When to start the music, in ms after the page was shown.
uint32_t still_alive_song_start_ms() const { return el512sa::StillAlivePlayer::songStartMs(); }
protected:
void draw_absolute_pixel_internal(int x, int y, Color color) override;
int get_width_internal() override { return EL512Panel::WIDTH; }
int get_height_internal() override { return EL512Panel::HEIGHT; }
EL512Pins pins_{18, 19, 21, 22, 23, -1, -1};
EL512Panel *panel_{nullptr};
bool enabled_{true};
bool low_power_{false};
display::DisplayPage *still_alive_page_{nullptr};
display::DisplayPage *still_alive_return_{nullptr};
bool still_alive_was_active_{false};
el512sa::StillAlivePlayer still_alive_;
};
} // namespace esphome::el512
+134
View File
@@ -0,0 +1,134 @@
// Classic 5x7 console font (printable ASCII 32..126), drawn in a 6x8 cell.
// Column-major, 5 bytes per character, bit 0 = top row.
//
// Taken from glcdfont.c of the Adafruit GFX Library
// (https://github.com/adafruit/Adafruit-GFX-Library), license below.
//
// Software License Agreement (BSD License)
//
// Copyright (c) 2012 Adafruit Industries. All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// - Redistributions of source code must retain the above copyright notice,
// this list of conditions and the following disclaimer.
// - Redistributions in binary form must reproduce the above copyright notice,
// this list of conditions and the following disclaimer in the documentation
// and/or other materials provided with the distribution.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE.
#pragma once
#include <stdint.h>
static constexpr char EL512_FONT_FIRST = 32;
static constexpr char EL512_FONT_LAST = 126;
static const uint8_t EL512_FONT[] = {
0x00, 0x00, 0x00, 0x00, 0x00, // space
0x00, 0x00, 0x5F, 0x00, 0x00, // !
0x00, 0x07, 0x00, 0x07, 0x00, // "
0x14, 0x7F, 0x14, 0x7F, 0x14, // #
0x24, 0x2A, 0x7F, 0x2A, 0x12, // $
0x23, 0x13, 0x08, 0x64, 0x62, // %
0x36, 0x49, 0x56, 0x20, 0x50, // &
0x00, 0x08, 0x07, 0x03, 0x00, // '
0x00, 0x1C, 0x22, 0x41, 0x00, // (
0x00, 0x41, 0x22, 0x1C, 0x00, // )
0x2A, 0x1C, 0x7F, 0x1C, 0x2A, // *
0x08, 0x08, 0x3E, 0x08, 0x08, // +
0x00, 0x80, 0x70, 0x30, 0x00, // ,
0x08, 0x08, 0x08, 0x08, 0x08, // -
0x00, 0x00, 0x60, 0x60, 0x00, // .
0x20, 0x10, 0x08, 0x04, 0x02, // /
0x3E, 0x51, 0x49, 0x45, 0x3E, // 0
0x00, 0x42, 0x7F, 0x40, 0x00, // 1
0x72, 0x49, 0x49, 0x49, 0x46, // 2
0x21, 0x41, 0x49, 0x4D, 0x33, // 3
0x18, 0x14, 0x12, 0x7F, 0x10, // 4
0x27, 0x45, 0x45, 0x45, 0x39, // 5
0x3C, 0x4A, 0x49, 0x49, 0x31, // 6
0x41, 0x21, 0x11, 0x09, 0x07, // 7
0x36, 0x49, 0x49, 0x49, 0x36, // 8
0x46, 0x49, 0x49, 0x29, 0x1E, // 9
0x00, 0x00, 0x14, 0x00, 0x00, // :
0x00, 0x40, 0x34, 0x00, 0x00, // ;
0x00, 0x08, 0x14, 0x22, 0x41, // <
0x14, 0x14, 0x14, 0x14, 0x14, // =
0x00, 0x41, 0x22, 0x14, 0x08, // >
0x02, 0x01, 0x59, 0x09, 0x06, // ?
0x3E, 0x41, 0x5D, 0x59, 0x4E, // @
0x7C, 0x12, 0x11, 0x12, 0x7C, // A
0x7F, 0x49, 0x49, 0x49, 0x36, // B
0x3E, 0x41, 0x41, 0x41, 0x22, // C
0x7F, 0x41, 0x41, 0x41, 0x3E, // D
0x7F, 0x49, 0x49, 0x49, 0x41, // E
0x7F, 0x09, 0x09, 0x09, 0x01, // F
0x3E, 0x41, 0x41, 0x51, 0x73, // G
0x7F, 0x08, 0x08, 0x08, 0x7F, // H
0x00, 0x41, 0x7F, 0x41, 0x00, // I
0x20, 0x40, 0x41, 0x3F, 0x01, // J
0x7F, 0x08, 0x14, 0x22, 0x41, // K
0x7F, 0x40, 0x40, 0x40, 0x40, // L
0x7F, 0x02, 0x1C, 0x02, 0x7F, // M
0x7F, 0x04, 0x08, 0x10, 0x7F, // N
0x3E, 0x41, 0x41, 0x41, 0x3E, // O
0x7F, 0x09, 0x09, 0x09, 0x06, // P
0x3E, 0x41, 0x51, 0x21, 0x5E, // Q
0x7F, 0x09, 0x19, 0x29, 0x46, // R
0x26, 0x49, 0x49, 0x49, 0x32, // S
0x03, 0x01, 0x7F, 0x01, 0x03, // T
0x3F, 0x40, 0x40, 0x40, 0x3F, // U
0x1F, 0x20, 0x40, 0x20, 0x1F, // V
0x3F, 0x40, 0x38, 0x40, 0x3F, // W
0x63, 0x14, 0x08, 0x14, 0x63, // X
0x03, 0x04, 0x78, 0x04, 0x03, // Y
0x61, 0x59, 0x49, 0x4D, 0x43, // Z
0x00, 0x7F, 0x41, 0x41, 0x41, // [
0x02, 0x04, 0x08, 0x10, 0x20, // backslash
0x00, 0x41, 0x41, 0x41, 0x7F, // ]
0x04, 0x02, 0x01, 0x02, 0x04, // ^
0x40, 0x40, 0x40, 0x40, 0x40, // _
0x00, 0x03, 0x07, 0x08, 0x00, // `
0x20, 0x54, 0x54, 0x78, 0x40, // a
0x7F, 0x28, 0x44, 0x44, 0x38, // b
0x38, 0x44, 0x44, 0x44, 0x28, // c
0x38, 0x44, 0x44, 0x28, 0x7F, // d
0x38, 0x54, 0x54, 0x54, 0x18, // e
0x00, 0x08, 0x7E, 0x09, 0x02, // f
0x18, 0xA4, 0xA4, 0x9C, 0x78, // g
0x7F, 0x08, 0x04, 0x04, 0x78, // h
0x00, 0x44, 0x7D, 0x40, 0x00, // i
0x20, 0x40, 0x40, 0x3D, 0x00, // j
0x7F, 0x10, 0x28, 0x44, 0x00, // k
0x00, 0x41, 0x7F, 0x40, 0x00, // l
0x7C, 0x04, 0x78, 0x04, 0x78, // m
0x7C, 0x08, 0x04, 0x04, 0x78, // n
0x38, 0x44, 0x44, 0x44, 0x38, // o
0xFC, 0x18, 0x24, 0x24, 0x18, // p
0x18, 0x24, 0x24, 0x18, 0xFC, // q
0x7C, 0x08, 0x04, 0x04, 0x08, // r
0x48, 0x54, 0x54, 0x54, 0x24, // s
0x04, 0x04, 0x3F, 0x44, 0x24, // t
0x3C, 0x40, 0x40, 0x20, 0x7C, // u
0x1C, 0x20, 0x40, 0x20, 0x1C, // v
0x3C, 0x40, 0x30, 0x40, 0x3C, // w
0x44, 0x28, 0x10, 0x28, 0x44, // x
0x4C, 0x90, 0x90, 0x90, 0x7C, // y
0x44, 0x64, 0x54, 0x4C, 0x44, // z
0x00, 0x08, 0x36, 0x41, 0x00, // {
0x00, 0x00, 0x77, 0x00, 0x00, // |
0x00, 0x41, 0x36, 0x08, 0x00, // }
0x02, 0x01, 0x02, 0x04, 0x02, // ~
};
+108
View File
@@ -0,0 +1,108 @@
// Small helpers for page lambdas (available as el512::... in YAML lambdas).
#pragma once
#include <cstdio>
#include <string>
#include <vector>
#include "esphome/components/display/display.h"
namespace esphome::el512 {
// Split the "a|b|c;d|e|f" lists made by the Home Assistant package.
inline std::vector<std::string> split(const std::string &s, char sep) {
std::vector<std::string> out;
if (s.empty())
return out;
size_t start = 0;
while (true) {
const size_t end = s.find(sep, start);
out.push_back(s.substr(start, end == std::string::npos ? std::string::npos : end - start));
if (end == std::string::npos)
return out;
start = end + 1;
}
}
// Field i of a "|"-separated entry, or "" if missing.
inline std::string field(const std::string &entry, size_t i) {
const auto parts = split(entry, '|');
return i < parts.size() ? parts[i] : std::string();
}
// Material Design Icons glyph (UTF-8) for a Home Assistant weather condition.
inline const char *weather_icon(const std::string &c) {
if (c == "clear-night") return "\U000F0594";
if (c == "cloudy") return "\U000F0590";
if (c == "fog") return "\U000F0591";
if (c == "hail") return "\U000F0592";
if (c == "lightning") return "\U000F0593";
if (c == "lightning-rainy") return "\U000F067E";
if (c == "partlycloudy") return "\U000F0595";
if (c == "pouring") return "\U000F0596";
if (c == "rainy") return "\U000F0597";
if (c == "snowy") return "\U000F0598";
if (c == "snowy-rainy") return "\U000F067F";
if (c == "sunny") return "\U000F0599";
if (c == "windy") return "\U000F059D";
if (c == "windy-variant") return "\U000F059E";
return "\U000F05D6"; // exceptional / unknown
}
// Readable weather condition ("partlycloudy" -> "Partly cloudy").
inline std::string weather_text(const std::string &c) {
if (c == "clear-night") return "Clear";
if (c == "partlycloudy") return "Partly cloudy";
if (c == "lightning-rainy") return "Thunderstorm";
if (c == "snowy-rainy") return "Sleet";
if (c == "windy-variant") return "Windy";
if (c == "exceptional") return "Exceptional";
if (c.empty()) return "";
std::string s = c;
s[0] = (char) toupper(s[0]);
return s;
}
// Cut text with "..." so it fits in max_width pixels in the given font.
inline std::string fit(display::BaseFont *font, const std::string &text, int max_width) {
int w, x_off, baseline, h;
font->measure(text.c_str(), &w, &x_off, &baseline, &h);
if (w <= max_width)
return text;
std::string s = text;
while (!s.empty()) {
// Drop a whole UTF-8 character.
do {
s.pop_back();
} while (!s.empty() && (s.back() & 0xC0) == 0x80);
const std::string t = s + "...";
font->measure(t.c_str(), &w, &x_off, &baseline, &h);
if (w <= max_width)
return t;
}
return "...";
}
// 245 -> "4:05", 3725 -> "1:02:05".
inline std::string mmss(int seconds) {
if (seconds < 0)
seconds = 0;
char buf[16];
if (seconds >= 3600)
snprintf(buf, sizeof(buf), "%d:%02d:%02d", seconds / 3600, seconds / 60 % 60, seconds % 60);
else
snprintf(buf, sizeof(buf), "%d:%02d", seconds / 60, seconds % 60);
return buf;
}
// 1250 -> "1250 W", 4200 -> "4.20 kW".
inline std::string watts(float w) {
char buf[16];
if (w >= 1000 || w <= -1000)
snprintf(buf, sizeof(buf), "%.2f kW", w / 1000.0f);
else
snprintf(buf, sizeof(buf), "%.0f W", w);
return buf;
}
} // namespace esphome::el512
+335
View File
@@ -0,0 +1,335 @@
#include "el512_panel.h"
#include <driver/gpio.h>
#include <esp_attr.h>
#include <esp_heap_caps.h>
#include <esp_intr_alloc.h>
#include <esp_rom_gpio.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <soc/gpio_sig_map.h>
#include <soc/i2s_reg.h>
#include <soc/i2s_struct.h>
#include <soc/periph_defs.h>
#include <string.h>
#if __has_include(<esp_private/periph_ctrl.h>)
#include <esp_private/periph_ctrl.h>
#else
#include <driver/periph_ctrl.h>
#endif
#if __has_include(<esp32/rom/lldesc.h>)
#include <esp32/rom/lldesc.h>
#else
#include <rom/lldesc.h>
#endif
namespace {
// I2S0 clock source when clka_en = 0: PLL_D2_CLK, 160 MHz whatever the CPU clock.
constexpr uint32_t I2S_SOURCE_CLOCK_HZ = 160000000;
constexpr uint32_t I2S_BYTE_RATE_HZ = EL512Panel::PIXEL_CLOCK_HZ * EL512Panel::BYTES_PER_CLOCK;
constexpr uint32_t I2S_CLKM_DIV = I2S_SOURCE_CLOCK_HZ / I2S_BYTE_RATE_HZ;
static_assert(I2S_SOURCE_CLOCK_HZ % I2S_BYTE_RATE_HZ == 0,
"byte rate must divide 160 MHz exactly");
static_assert(I2S_CLKM_DIV >= 2 && I2S_CLKM_DIV <= 255, "clkm_div_num out of range");
// In 8-bit LCD mode I2S0 puts its byte on DATA_OUT16..23 (bitluni ESP32Lib).
constexpr uint32_t I2S0_DATA_SIGNAL_BASE = I2S0O_DATA_OUT16_IDX;
// BCK runs at half the byte rate (measured: 2.5 MHz BCK at 5 MHz bytes), so each
// VCLK period spans two identical bytes. Uninverted, the rising edge landed on the
// byte-pair boundary and the image jittered; inverted, it sits mid-pair (verified
// on the panel).
constexpr bool INVERT_VCLK = true;
static_assert(EL512Panel::LINES_PER_CHUNK * EL512Panel::ROW_BYTES <= 4092, "descriptor too long");
static_assert(EL512Panel::ROW_BYTES % 4 == 0, "rows must be whole DMA words");
static_assert(EL512Panel::RING_LINES < EL512Panel::LINES_PER_FRAME, "ring longer than a frame");
constexpr uint16_t FB_ROW_BYTES = EL512Panel::WIDTH / 8;
constexpr uint16_t DATA_BYTES = EL512Panel::DATA_CLOCKS_PER_ROW * EL512Panel::BYTES_PER_CLOCK;
// One framebuffer byte (8 pixels = 4 clocks) -> 8 stream bytes as two DMA words,
// already in I2S byte order. Built once in begin(); read from the ISR.
DRAM_ATTR uint32_t s_lut[256][2];
void buildLut() {
for (int b = 0; b < 256; b++) {
uint8_t bytes[8];
for (int c = 0; c < 4; c++) {
// Clock c carries x = 2c and 2c+1. The datasheet numbers columns from 1, so
// x = 2c is column 2c+1 (odd -> TVID) and x = 2c+1 is column 2c+2 (even -> VID).
const uint8_t bits = (uint8_t)(b << (c * 2));
uint8_t v = EL512Panel::BIT_HS;
if (bits & 0x80) v |= EL512Panel::BIT_TVID;
if (bits & 0x40) v |= EL512Panel::BIT_VID;
bytes[(2 * c) ^ 2] = v;
bytes[(2 * c + 1) ^ 2] = v;
}
memcpy(s_lut[b], bytes, sizeof(bytes));
}
}
inline uint32_t IRAM_ATTR repeat4(uint8_t v) { return v * 0x01010101u; }
void resetI2S0() {
const uint32_t lcReset = I2S_IN_RST_M | I2S_OUT_RST_M | I2S_AHBM_RST_M | I2S_AHBM_FIFO_RST_M;
I2S0.lc_conf.val |= lcReset;
I2S0.lc_conf.val &= ~lcReset;
const uint32_t confReset =
I2S_RX_RESET_M | I2S_RX_FIFO_RESET_M | I2S_TX_RESET_M | I2S_TX_FIFO_RESET_M;
I2S0.conf.val |= confReset;
I2S0.conf.val &= ~confReset;
}
void routeOutput(int8_t pin, uint32_t signal, bool invert) {
if (pin < 0) return;
esp_rom_gpio_pad_select_gpio(pin);
gpio_set_direction((gpio_num_t)pin, GPIO_MODE_OUTPUT);
esp_rom_gpio_connect_out_signal(pin, signal, invert, false);
}
void setupGpioOutput(int8_t pin, bool level) {
if (pin < 0) return;
esp_rom_gpio_pad_select_gpio(pin);
gpio_set_direction((gpio_num_t)pin, GPIO_MODE_OUTPUT);
gpio_set_level((gpio_num_t)pin, level);
}
} // namespace
EL512Panel::EL512Panel(const EL512Pins& pins) : _pins(pins) {}
EL512Panel::~EL512Panel() { end(); }
bool EL512Panel::begin() {
if (_running) return true;
setupGpioOutput(_pins.enable, false);
setupGpioOutput(_pins.lowpwr_n, true);
_enabled = false;
_lowPower = false;
buildLut();
// Both framebuffers are read from the ISR, so they must be internal RAM.
_fb = (uint8_t*)heap_caps_calloc(1, FRAMEBUFFER_BYTES, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
_front = (uint8_t*)heap_caps_calloc(1, FRAMEBUFFER_BYTES, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
_ring = (uint8_t*)heap_caps_malloc(RING_BYTES, MALLOC_CAP_DMA | MALLOC_CAP_8BIT);
if (!_fb || !_front || !_ring || !startI2S()) {
end();
return false;
}
_running = true;
setEnabled(true);
return true;
}
void EL512Panel::end() {
if (_pins.enable >= 0) gpio_set_level((gpio_num_t)_pins.enable, 0);
_enabled = false;
stopI2S();
_running = false;
heap_caps_free(_fb);
_fb = nullptr;
heap_caps_free(_front);
_front = nullptr;
heap_caps_free(_ring);
_ring = nullptr;
}
void EL512Panel::setEnabled(bool on) {
_enabled = on;
if (_pins.enable >= 0) gpio_set_level((gpio_num_t)_pins.enable, on);
}
void EL512Panel::setLowPower(bool on) {
_lowPower = on;
if (_pins.lowpwr_n >= 0) gpio_set_level((gpio_num_t)_pins.lowpwr_n, !on);
}
void EL512Panel::setPixel(int16_t x, int16_t y, bool on) {
if (!_fb || x < 0 || y < 0 || x >= WIDTH || y >= HEIGHT) return;
uint8_t& b = _fb[(y * WIDTH + x) >> 3];
const uint8_t mask = 0x80 >> (x & 7);
if (on)
b |= mask;
else
b &= ~mask;
}
bool EL512Panel::getPixel(int16_t x, int16_t y) const {
if (!_fb || x < 0 || y < 0 || x >= WIDTH || y >= HEIGHT) return false;
return _fb[(y * WIDTH + x) >> 3] & (0x80 >> (x & 7));
}
void EL512Panel::clear(bool on) {
if (_fb) memset(_fb, on ? 0xFF : 0x00, FRAMEBUFFER_BYTES);
}
void EL512Panel::show() {
if (!_fb || !_front) return;
if (!_running) {
memcpy(_front, _fb, FRAMEBUFFER_BYTES);
return;
}
// The ISR copies _fb to _front just before it encodes line 0 of a frame.
_swapPending = true;
for (int i = 0; i < 40 && _swapPending; i++) vTaskDelay(1);
}
void IRAM_ATTR EL512Panel::encodeRow(uint16_t line, uint8_t* out) const {
// VS is held high during the last blanking line so that the first HS high
// time ending after its rising edge is row 0 (datasheet: Video Data Input).
const uint32_t vs = repeat4(line == LINES_PER_FRAME - 1 ? BIT_VS : 0);
uint32_t* o = (uint32_t*)out;
if (line >= HEIGHT) {
for (uint16_t i = 0; i < ROW_BYTES / 4; i++) o[i] = vs;
return;
}
const uint8_t* src = _front + (size_t)line * FB_ROW_BYTES;
for (uint16_t k = 0; k < FB_ROW_BYTES; k++) {
o[2 * k] = s_lut[src[k]][0];
o[2 * k + 1] = s_lut[src[k]][1];
}
// Blanking clocks: HS low. Uniform and word-aligned, so no byte swap needed.
for (uint16_t i = DATA_BYTES / 4; i < ROW_BYTES / 4; i++) o[i] = vs;
}
void IRAM_ATTR EL512Panel::fillChunk(uint16_t chunk) {
uint16_t line = _chunkLine[chunk];
uint8_t* out = _ring + (size_t)chunk * LINES_PER_CHUNK * ROW_BYTES;
for (uint16_t i = 0; i < LINES_PER_CHUNK; i++) {
if (line == 0 && _swapPending) {
const uint32_t* s = (const uint32_t*)_fb;
uint32_t* d = (uint32_t*)_front;
for (size_t w = 0; w < FRAMEBUFFER_BYTES / 4; w++) d[w] = s[w];
_swapPending = false;
}
encodeRow(line, out + (size_t)i * ROW_BYTES);
line = (line + 1 == LINES_PER_FRAME) ? 0 : line + 1;
}
}
void IRAM_ATTR EL512Panel::isr(void* arg) {
auto* self = static_cast<EL512Panel*>(arg);
const uint32_t status = I2S0.int_st.val;
I2S0.int_clr.val = status;
if (!(status & I2S_OUT_EOF_INT_ST_M)) return;
// The descriptor that just finished plays again RING_LINES lines later.
const lldesc_t* done = (const lldesc_t*)I2S0.out_eof_des_addr;
const uint16_t chunk = done - (const lldesc_t*)self->_desc;
if (chunk >= CHUNK_COUNT) return;
uint16_t next = self->_chunkLine[chunk] + RING_LINES;
if (next >= LINES_PER_FRAME) next -= LINES_PER_FRAME;
self->_chunkLine[chunk] = next;
self->fillChunk(chunk);
}
bool EL512Panel::startI2S() {
_desc = (lldesc_t*)heap_caps_calloc(CHUNK_COUNT, sizeof(lldesc_t), MALLOC_CAP_DMA);
if (!_desc) return false;
for (uint16_t i = 0; i < CHUNK_COUNT; i++) {
_chunkLine[i] = i * LINES_PER_CHUNK;
fillChunk(i);
lldesc_t& d = _desc[i];
d.size = LINES_PER_CHUNK * ROW_BYTES;
d.length = d.size;
d.owner = 1;
d.eof = 1; // EOF interrupt after every chunk
d.buf = _ring + (size_t)i * LINES_PER_CHUNK * ROW_BYTES;
d.qe.stqe_next = &_desc[(i + 1) % CHUNK_COUNT];
}
periph_module_enable(PERIPH_I2S0_MODULE);
resetI2S0();
// 8-bit parallel LCD mode (bitluni ESP32Lib setup; the i ^ 2 byte order in
// the lookup table depends on these FIFO/channel modes).
I2S0.conf2.val = 0;
I2S0.conf2.lcd_en = 1;
I2S0.conf2.lcd_tx_wrx2_en = 1;
I2S0.conf2.lcd_tx_sdx2_en = 0;
I2S0.sample_rate_conf.val = 0;
I2S0.sample_rate_conf.tx_bits_mod = 8;
I2S0.sample_rate_conf.tx_bck_div_num = 1;
// PLL_D2_CLK / (N + b/a) with b = 0: 160 MHz / 16 = 10 MHz bytes -> 5 MHz BCK.
I2S0.clkm_conf.val = 0;
I2S0.clkm_conf.clka_en = 0;
I2S0.clkm_conf.clkm_div_num = I2S_CLKM_DIV;
I2S0.clkm_conf.clkm_div_a = 1;
I2S0.clkm_conf.clkm_div_b = 0;
I2S0.fifo_conf.val = 0;
I2S0.fifo_conf.tx_fifo_mod_force_en = 1;
I2S0.fifo_conf.tx_fifo_mod = 1;
I2S0.fifo_conf.tx_data_num = 32;
I2S0.fifo_conf.dscr_en = 1;
I2S0.conf1.val = 0;
I2S0.conf1.tx_stop_en = 0;
I2S0.conf1.tx_pcm_bypass = 1;
I2S0.conf_chan.val = 0;
I2S0.conf_chan.tx_chan_mod = 1;
I2S0.conf.tx_right_first = 1;
I2S0.conf.tx_msb_right = 0;
I2S0.conf.tx_msb_shift = 0;
I2S0.conf.tx_mono = 0;
I2S0.conf.tx_short_sync = 0;
I2S0.timing.val = 0;
// Stream bit n -> DATA_OUT(16 + n); BCK -> VCLK.
const int8_t dataPins[] = {_pins.vid, _pins.tvid, _pins.hs, _pins.vs};
for (uint32_t bit = 0; bit < sizeof(dataPins); bit++) {
routeOutput(dataPins[bit], I2S0_DATA_SIGNAL_BASE + bit, false);
}
routeOutput(_pins.vclk, I2S0O_BCK_OUT_IDX, INVERT_VCLK);
// EOF interrupt refills the ring. IRAM so it keeps running during flash writes.
I2S0.int_ena.val = 0;
I2S0.int_clr.val = I2S0.int_raw.val;
if (esp_intr_alloc(ETS_I2S0_INTR_SOURCE, ESP_INTR_FLAG_IRAM, &EL512Panel::isr, this,
&_intr) != ESP_OK) {
_intr = nullptr;
return false;
}
I2S0.int_ena.out_eof = 1;
// Start the DMA; it loops over the chain until stopI2S().
I2S0.lc_conf.val = I2S_OUT_DATA_BURST_EN_M | I2S_OUTDSCR_BURST_EN_M;
I2S0.out_link.addr = (uint32_t)&_desc[0];
I2S0.out_link.start = 1;
I2S0.conf.tx_start = 1;
return true;
}
void EL512Panel::stopI2S() {
if (!_desc) return; // never started
I2S0.int_ena.val = 0;
if (_intr) {
esp_intr_free(_intr);
_intr = nullptr;
}
I2S0.conf.tx_start = 0;
I2S0.out_link.stop = 1;
resetI2S0();
periph_module_disable(PERIPH_I2S0_MODULE);
// Hand the pins back to GPIO and park them low.
const int8_t pins[] = {_pins.vclk, _pins.vid, _pins.tvid, _pins.hs, _pins.vs};
for (int8_t pin : pins) {
if (pin < 0) continue;
esp_rom_gpio_connect_out_signal(pin, SIG_GPIO_OUT_IDX, false, false);
gpio_set_level((gpio_num_t)pin, 0);
}
heap_caps_free(_desc);
_desc = nullptr;
}
+128
View File
@@ -0,0 +1,128 @@
// EL512Panel - refresh driver for the Planar EL512.256-H on an ESP32.
//
// Uses only ESP-IDF APIs, so the same code builds for Arduino/PlatformIO
// (lib/EL512) and for the ESPHome component in this folder.
//
// The panel has no frame memory: it must be refreshed continuously. I2S0 in
// 8-bit parallel (LCD) mode streams the signals by DMA from a small ring of
// encoded lines (CHUNK_COUNT chunks of LINES_PER_CHUNK lines). When a chunk has
// been sent, the DMA EOF interrupt re-encodes it with the lines that come
// RING_LINES later, so the ring stays ~1.7 ms ahead of the panel.
//
// byte bit 0 = VID (even columns 2..512 = x 1, 3, ..., 511)
// byte bit 1 = TVID (odd columns 1..511 = x 0, 2, ..., 510)
// byte bit 2 = HS (high while the 256 data clocks of a row are sent)
// byte bit 3 = VS (marks the top row)
// I2S BCK -> VCLK (inverted, see the .cpp)
//
// BCK rises once per two stream bytes (measured with PCNT), so every VCLK
// period is two identical bytes; the I2S byte rate is 10 MHz for a 5 MHz VCLK.
// In 8-bit LCD mode the I2S emits each 32-bit DMA word as bytes 2, 3, 0, 1, so
// stream byte i is stored at index i ^ 2.
//
// Panel in two-bit parallel mode (PS1 jumper 2 removed, datasheet Table 3):
// 272 clocks per row (256 data + 16 blanking), 261 rows per frame
// (256 visible + 5 blanking) -> 5e6 / (272 * 261) ~= 70.4 Hz.
//
// Drawing goes into a back buffer; show() copies it to the displayed buffer at
// the start of the next frame, so a redraw never shows half-drawn.
#pragma once
#include <stddef.h>
#include <stdint.h>
struct lldesc_s; // ESP32 DMA descriptor
struct intr_handle_data_t; // esp_intr_alloc handle
struct EL512Pins {
int8_t vclk;
int8_t vid;
int8_t tvid;
int8_t hs;
int8_t vs;
int8_t enable; // -1 if not connected (panel pulls it high)
int8_t lowpwr_n; // -1 if not connected (panel pulls it high)
};
class EL512Panel {
public:
static constexpr uint16_t WIDTH = 512;
static constexpr uint16_t HEIGHT = 256;
// Timing (two-bit parallel mode)
static constexpr uint32_t PIXEL_CLOCK_HZ = 5000000;
static constexpr uint16_t DATA_CLOCKS_PER_ROW = WIDTH / 2; // 2 px per VCLK
static constexpr uint16_t CLOCKS_PER_ROW = 272;
static constexpr uint16_t BLANK_CLOCKS_PER_ROW = CLOCKS_PER_ROW - DATA_CLOCKS_PER_ROW;
static constexpr uint16_t LINES_PER_FRAME = 261;
static constexpr uint16_t BLANK_LINES = LINES_PER_FRAME - HEIGHT;
// DMA stream byte bits
static constexpr uint8_t BIT_VID = 1 << 0;
static constexpr uint8_t BIT_TVID = 1 << 1;
static constexpr uint8_t BIT_HS = 1 << 2;
static constexpr uint8_t BIT_VS = 1 << 3;
// Stream bytes per VCLK period (BCK runs at half the I2S byte rate).
static constexpr uint8_t BYTES_PER_CLOCK = 2;
static constexpr uint16_t ROW_BYTES = CLOCKS_PER_ROW * BYTES_PER_CLOCK;
// DMA ring: CHUNK_COUNT descriptors of LINES_PER_CHUNK lines each.
static constexpr uint16_t LINES_PER_CHUNK = 4;
static constexpr uint16_t CHUNK_COUNT = 8;
static constexpr uint16_t RING_LINES = LINES_PER_CHUNK * CHUNK_COUNT;
static constexpr size_t RING_BYTES = (size_t)RING_LINES * ROW_BYTES;
static constexpr size_t FRAMEBUFFER_BYTES = (size_t)WIDTH * HEIGHT / 8;
explicit EL512Panel(const EL512Pins& pins);
~EL512Panel();
// Allocates buffers, sets up control GPIOs and starts the I2S DMA refresh.
bool begin();
void end();
bool running() const { return _running; }
// Display on/off (ENABLE) and low-power mode (_LOWPWR, active low).
void setEnabled(bool on);
void setLowPower(bool on);
bool enabled() const { return _enabled; }
bool lowPower() const { return _lowPower; }
// 1 bpp drawing buffer, row-major, MSB = leftmost pixel.
uint8_t* framebuffer() { return _fb; }
void setPixel(int16_t x, int16_t y, bool on);
bool getPixel(int16_t x, int16_t y) const;
void clear(bool on = false);
// Hand the drawing buffer to the refresh. Copies it at the start of the next
// frame and waits for that (at most one frame, ~14 ms).
void show();
// Encode one scan line (0..LINES_PER_FRAME-1) of the displayed buffer into
// ROW_BYTES bytes in DMA order. Lines >= HEIGHT produce blanking.
void encodeRow(uint16_t line, uint8_t* out) const;
// Frame refresh rate implied by the timing constants.
static constexpr float refreshHz() {
return (float)PIXEL_CLOCK_HZ / ((float)CLOCKS_PER_ROW * LINES_PER_FRAME);
}
private:
bool startI2S();
void stopI2S();
void fillChunk(uint16_t chunk);
static void isr(void* arg);
EL512Pins _pins;
uint8_t* _fb = nullptr; // drawing buffer
uint8_t* _front = nullptr; // buffer being displayed
uint8_t* _ring = nullptr; // RING_BYTES, DMA-capable
lldesc_s* _desc = nullptr; // CHUNK_COUNT descriptors, circular
intr_handle_data_t* _intr = nullptr;
uint16_t _chunkLine[CHUNK_COUNT] = {}; // first line currently in each chunk
volatile bool _swapPending = false;
bool _running = false;
bool _enabled = false;
bool _lowPower = false;
};
+10
View File
@@ -0,0 +1,10 @@
{
"name": "EL512Core",
"version": "0.2.0",
"description": "Planar EL512.256-H refresh driver core (ESP-IDF API). Shared by lib/EL512 and the ESPHome component in this folder.",
"frameworks": ["arduino", "espidf"],
"platforms": "espressif32",
"build": {
"srcFilter": ["-<*>", "+<el512_panel.cpp>", "+<still_alive.cpp>"]
}
}
+252
View File
@@ -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
+110
View File
@@ -0,0 +1,110 @@
// Portal-style end-credits show for the EL512.256-H: timed song text typed into
// a console on the left, credits typed top right, ASCII art bottom right.
//
// Framework-agnostic: draws straight into an EL512Panel with the built-in 5x7
// font, so the PlatformIO demo (src/still_alive) and the ESPHome component use
// the same code. The content is in still_alive_data.h, generated by
// tools/import_still_alive.py.
#pragma once
#include <stddef.h>
#include <stdint.h>
class EL512Panel;
namespace el512sa {
enum : uint8_t {
STEP_NEWLINE = 1 << 0, // start a new line after the text is typed
STEP_CLEAR = 1 << 1, // clear the console before typing
};
// One timed piece of console text. Steps are sorted by startMs.
struct LyricStep {
uint32_t startMs; // from the start of the show
uint16_t durMs; // the text is typed out evenly over this time
uint8_t flags;
int8_t art; // picture to show (index into ARTS), or ART_KEEP
const char *text;
};
struct Art {
const char *const *lines;
uint8_t count;
};
static constexpr int8_t ART_KEEP = -1; // leave the current picture
static constexpr int8_t ART_NONE = 0; // ARTS[0] is always the empty picture
// Fixed-size character console in the 5x7 font (6x8 cells), optionally inside a
// dashed frame. Text wraps at the right edge and scrolls up past the bottom row.
class TextBox {
public:
static constexpr uint8_t MAX_COLS = 48;
static constexpr uint8_t MAX_ROWS = 32;
static constexpr int16_t PAD = 4;
static constexpr int16_t CHAR_W = 6;
static constexpr int16_t CHAR_H = 8;
TextBox(int16_t x, int16_t y, int16_t w, int16_t h);
uint8_t cols() const { return cols_; }
uint8_t rows() const { return rows_; }
void clear();
void put(char c);
void print(const char *s);
void draw(EL512Panel &panel, bool cursor, bool frame = true) const;
private:
void newLine();
int16_t x_, y_, w_, h_;
uint8_t cols_, rows_;
uint8_t row_ = 0, col_ = 0;
char text_[MAX_ROWS][MAX_COLS + 1];
};
class StillAlivePlayer {
public:
static constexpr uint32_t START_DELAY_MS = 1000; // after start()
static constexpr uint32_t CURSOR_BLINK_MS = 300;
static constexpr uint32_t HOLD_MS = 10000; // after the last step, before finished()
StillAlivePlayer();
// Restart the show; it begins START_DELAY_MS after nowMs.
void start(uint32_t nowMs);
// Advance to nowMs; true if the screen changed (text, picture or cursor).
bool update(uint32_t nowMs);
// Draw the whole show; the caller clears the panel first and shows it after.
void draw(EL512Panel &panel) const;
// True once the last step has been typed and HOLD_MS has passed.
bool finished(uint32_t nowMs) const;
// When to start the music, relative to start().
static uint32_t songStartMs();
private:
bool updateLyrics(uint32_t t, uint32_t nowMs);
bool updateCredits(uint32_t t);
void showArt(int8_t id);
TextBox lyrics_;
TextBox credits_;
TextBox art_;
uint32_t showStart_ = 0;
size_t step_ = 0;
size_t charIdx_ = 0;
bool stepStarted_ = false;
bool done_ = false;
uint32_t doneAt_ = 0;
size_t creditIdx_ = 0;
size_t creditChar_ = 0;
bool creditLineOpen_ = false;
bool creditsDone_ = false;
uint32_t creditLineAt_ = 0;
bool cursor_ = false;
bool creditsCursor_ = false;
};
} // namespace el512sa
@@ -11,7 +11,9 @@
// Generated by tools/import_still_alive.py - do not edit by hand. // Generated by tools/import_still_alive.py - do not edit by hand.
#pragma once #pragma once
#include "show_data.h" #include "still_alive.h"
namespace el512sa {
static const char* const ART_APERTURE_LINES[] = { static const char* const ART_APERTURE_LINES[] = {
" .,-:;//;:=,", " .,-:;//;:=,",
@@ -512,28 +514,6 @@ static const LyricStep LYRICS[] = {
{195250, 1500, STEP_NEWLINE, ART_KEEP, " and/or registered trademarks of"}, {195250, 1500, STEP_NEWLINE, ART_KEEP, " and/or registered trademarks of"},
{196750, 1500, STEP_NEWLINE, ART_KEEP, " Valve Corporation. All other trademarks"}, {196750, 1500, STEP_NEWLINE, ART_KEEP, " Valve Corporation. All other trademarks"},
{198250, 1500, STEP_NEWLINE, ART_KEEP, " are property of their respective owners."}, {198250, 1500, STEP_NEWLINE, ART_KEEP, " are property of their respective owners."},
{200250, 0, STEP_NEWLINE, ART_KEEP, ""},
{200750, 2000, STEP_NEWLINE, ART_KEEP, " For the full legal info, please refer"},
{204250, 0, STEP_NEWLINE, ART_KEEP, ""},
{229250, 0, STEP_NEWLINE, ART_KEEP, ""},
{229350, 0, STEP_NEWLINE, ART_KEEP, ""},
{229450, 0, STEP_NEWLINE, ART_KEEP, ""},
{229750, 4000, STEP_NEWLINE, ART_KEEP, "... is anyone there?"},
{241750, 3000, STEP_NEWLINE, ART_KEEP, "I think you can leave here now."},
{245250, 0, STEP_NEWLINE, ART_KEEP, ""},
{246250, 2000, STEP_NEWLINE, ART_KEEP, "Here would be no profit"},
{248750, 2000, STEP_NEWLINE, ART_KEEP, "if you stay here more and more."},
{273939, 0, STEP_CLEAR, ART_KEEP, ""},
{274439, 0, STEP_NEWLINE, ART_KEEP, ""},
{274939, 0, STEP_NEWLINE, ART_KEEP, ""},
{275439, 0, STEP_NEWLINE, ART_KEEP, ""},
{275939, 5000, STEP_NEWLINE, ART_KEEP, "... will you play a game with me?"},
{281439, 2000, STEP_NEWLINE, ART_KEEP, "I know a simple game, called"},
{283939, 0, STEP_NEWLINE, ART_KEEP, ""},
{284939, 5000, STEP_NEWLINE, ART_KEEP, "// Getting Faster and Faster //"},
{290939, 2000, STEP_NEWLINE, ART_KEEP, "... I just said this is a simple game."},
{293439, 500, 0, ART_KEEP, "Actually, "},
{294939, 3500, 0, ART_KEEP, "THIS IS NOT A GAME. "},
}; };
static constexpr size_t LYRIC_COUNT = sizeof(LYRICS) / sizeof(LYRICS[0]); static constexpr size_t LYRIC_COUNT = sizeof(LYRICS) / sizeof(LYRICS[0]);
@@ -782,3 +762,5 @@ static constexpr uint32_t SONG_START_MS = 6750;
static constexpr uint32_t CREDITS_START_MS = 9000; static constexpr uint32_t CREDITS_START_MS = 9000;
static constexpr uint32_t CREDIT_CHAR_US = 68624; static constexpr uint32_t CREDIT_CHAR_US = 68624;
static constexpr bool CREDITS_LOOP = false; static constexpr bool CREDITS_LOOP = false;
} // namespace el512sa
+5
View File
@@ -0,0 +1,5 @@
# Gitignore settings for ESPHome
# This is an example and may include too much for your use-case.
# You can modify this file to suit your needs.
/.esphome/
/secrets.yaml
+557
View File
@@ -0,0 +1,557 @@
# Planar EL512.256-H (512x256 amber EL) on a Wemos D1 mini ESP32, as a
# Home Assistant information display.
#
# Needs: secrets.yaml (wifi_ssid, wifi_password, el512_api_key, el512_ota_password)
# and the helper sensors from home_assistant/packages/el512_panel.yaml.
# Wiring: see the README of https://mangavalk.com/mangavalk/planar-EL512.256-H
substitutions:
name: el512-panel
friendly_name: EL512 Panel
weather: weather.knmi_home
envoy: sensor.envoy_122225067403
esphome:
name: ${name}
friendly_name: ${friendly_name}
esp32:
board: wemos_d1_mini32
framework:
type: esp-idf
external_components:
- source:
type: git
url: https://mangavalk.com/mangavalk/planar-EL512.256-H.git
ref: main
components: [el512]
refresh: 0s
logger:
api:
encryption:
key: !secret el512_api_key
ota:
- platform: esphome
password: !secret el512_ota_password
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap:
ssid: ${name}
time:
- platform: homeassistant
id: ha_time
timezone: Europe/Amsterdam
globals:
- id: rotation_elapsed
type: int
initial_value: "0"
- id: np_updated
type: uint32_t
initial_value: "0"
# --- Fonts -------------------------------------------------------------------
font:
- file: "gfonts://Roboto"
id: f_s
size: 16
glyphsets: [GF_Latin_Core]
- file: "gfonts://Roboto"
id: f_m
size: 22
glyphsets: [GF_Latin_Core]
- file: "gfonts://Roboto"
id: f_l
size: 32
glyphsets: [GF_Latin_Core]
- file: "gfonts://Roboto@500"
id: f_big
size: 48
glyphs: " 0123456789.,:-+%°CWkhMbits/"
- file: "gfonts://Roboto@300"
id: f_clock
size: 120
glyphs: "0123456789:"
- file: &mdi
type: web
url: https://github.com/Templarian/MaterialDesign-Webfont/raw/master/fonts/materialdesignicons-webfont.ttf
id: icons_xl
size: 96
glyphs: &weather_icons
- "\U000F0594" # weather-night
- "\U000F0590" # weather-cloudy
- "\U000F0591" # weather-fog
- "\U000F0592" # weather-hail
- "\U000F0593" # weather-lightning
- "\U000F067E" # weather-lightning-rainy
- "\U000F0595" # weather-partly-cloudy
- "\U000F0596" # weather-pouring
- "\U000F0597" # weather-rainy
- "\U000F0598" # weather-snowy
- "\U000F067F" # weather-snowy-rainy
- "\U000F0599" # weather-sunny
- "\U000F059D" # weather-windy
- "\U000F059E" # weather-windy-variant
- "\U000F05D6" # alert-circle-outline
- file: *mdi
id: icons_l
size: 56
glyphs: *weather_icons
- file: *mdi
id: icons_m
size: 32
glyphs:
- "\U000F0A72" # solar-power
- "\U000F0D3E" # transmission-tower
- "\U000F06A1" # home-outline
- "\U000F06BA" # plex
- "\U000F0E17" # calendar-month
- "\U000F040A" # play
- "\U000F0387" # music-note
- "\U000F048D" # server-network
- "\U000F048E" # server-network-off
- "\U000F06B0" # update
- "\U000F054B" # umbrella-outline
- "\U000F072E" # arrow-down-bold
- "\U000F0737" # arrow-up-bold
- file: *mdi
id: icons_s
size: 18
glyphs:
- "\U000F054B" # umbrella-outline
# --- Data from Home Assistant -------------------------------------------------
text_sensor:
- platform: homeassistant
id: weather_cond
entity_id: ${weather}
- platform: homeassistant
id: forecast_days
entity_id: sensor.el512_forecast
attribute: days
- platform: homeassistant
id: agenda_events
entity_id: sensor.el512_agenda
attribute: events
- platform: homeassistant
id: np_state
entity_id: sensor.el512_now_playing
- platform: homeassistant
id: np_info
entity_id: sensor.el512_now_playing
attribute: info
on_value:
- lambda: id(np_updated) = millis();
- platform: homeassistant
id: plex_server
entity_id: sensor.mangavalkserver
- platform: homeassistant
id: plex_streams
entity_id: sensor.el512_plex_streams
attribute: streams
- platform: homeassistant
id: plex_update
entity_id: update.plex_media_server_mangavalkserver
- platform: homeassistant
id: plex_version
entity_id: update.plex_media_server_mangavalkserver
attribute: installed_version
sensor:
- platform: homeassistant
id: weather_temp
entity_id: ${weather}
attribute: temperature
- platform: homeassistant
id: weather_hum
entity_id: ${weather}
attribute: humidity
- platform: homeassistant
id: weather_wind
entity_id: ${weather}
attribute: wind_speed
# Plex / Tautulli
- platform: homeassistant
id: plex_watching
entity_id: sensor.mangavalkserver
- platform: homeassistant
id: tautulli_bandwidth
entity_id: sensor.tautulli_total_bandwidth
# Solar (Enphase Envoy) and grid
- platform: homeassistant
id: solar_power
entity_id: ${envoy}_current_power_production
- platform: homeassistant
id: solar_today
entity_id: ${envoy}_today_s_energy_production
- platform: homeassistant
id: house_power
entity_id: ${envoy}_current_power_consumption
- platform: homeassistant
id: grid_import
entity_id: sensor.grid_import_power
- platform: homeassistant
id: grid_export
entity_id: sensor.grid_export_power
# Room temperatures (thermostat zones)
- platform: homeassistant
id: t_woonkamer
entity_id: climate.woonkamer
attribute: current_temperature
- platform: homeassistant
id: t_voorkamer
entity_id: climate.voorkamer
attribute: current_temperature
- platform: homeassistant
id: t_gang
entity_id: climate.vloerverwarm
attribute: current_temperature
- platform: homeassistant
id: t_patrick
entity_id: climate.patrick
attribute: current_temperature
- platform: homeassistant
id: t_badkamer
entity_id: climate.badkamer
attribute: current_temperature
- platform: homeassistant
id: t_stans_paul
entity_id: climate.stans_paul
attribute: current_temperature
- platform: homeassistant
id: t_vloer
entity_id: climate.vloererwarmin
attribute: current_temperature
- platform: homeassistant
id: t_huis
entity_id: climate.vigilantestraat
attribute: current_temperature
graph:
- id: solar_graph
sensor: solar_power
duration: 16h
width: 496
height: 118
x_grid: 2h
y_grid: 1000
min_value: 0
# --- Controls in Home Assistant ------------------------------------------------
switch:
- platform: template
id: sw_display
name: Display
icon: mdi:monitor
restore_mode: RESTORE_DEFAULT_ON
lambda: return id(panel).is_enabled();
turn_on_action:
- lambda: id(panel).set_enabled(true);
turn_off_action:
- lambda: id(panel).set_enabled(false);
- platform: template
id: sw_low_power
name: Low power mode
icon: mdi:leaf
restore_mode: RESTORE_DEFAULT_OFF
lambda: return id(panel).is_low_power();
turn_on_action:
- lambda: id(panel).set_low_power(true);
turn_off_action:
- lambda: id(panel).set_low_power(false);
- platform: template
id: sw_pause
name: Pause page rotation
icon: mdi:pause-circle-outline
optimistic: true
restore_mode: RESTORE_DEFAULT_OFF
number:
- platform: template
id: page_interval
name: Page interval
icon: mdi:timer-outline
unit_of_measurement: s
min_value: 5
max_value: 300
step: 5
initial_value: 20
optimistic: true
restore_value: true
text:
- platform: template
id: panel_message
name: Message
icon: mdi:message-text-outline
mode: text
max_length: 100
optimistic: true
restore_value: true
initial_value: ""
on_value:
- component.update: panel
select:
- platform: template
id: page_select
name: Page
icon: mdi:book-open-page-variant-outline
options:
- Clock
- Forecast
- Agenda
- Dashboard
- Now playing
- Plex
- Solar
- Still Alive
initial_option: Clock
set_action:
- lambda: |-
id(rotation_elapsed) = 0;
if (x == "Still Alive") { id(panel).play_still_alive(); id(panel).update(); return; }
display::DisplayPage *p = id(page_clock);
if (x == "Forecast") p = id(page_forecast);
else if (x == "Agenda") p = id(page_agenda);
else if (x == "Dashboard") p = id(page_dashboard);
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);
id(panel).show_page(p);
id(panel).update();
button:
- platform: template
name: Play Still Alive
icon: mdi:cake-variant-outline
on_press:
- lambda: |-
id(panel).play_still_alive();
id(panel).update();
# Rotate pages; skip Still Alive (start it from HA) and Now playing when idle.
interval:
- interval: 1s
then:
- lambda: |-
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++) {
id(panel).show_next_page();
const auto *p = id(panel).get_active_page();
const std::string &np = id(np_state).state;
if (p == id(page_still_alive)) continue;
if (p == id(page_media) && (np == "idle" || np.empty())) continue;
break;
}
id(panel).update();
# --- The panel and its pages ----------------------------------------------------
display:
- platform: el512
id: panel
enable_pin: GPIO25
low_power_pin: GPIO26
update_interval: 1s
still_alive_page: page_still_alive
on_page_change:
- then:
- lambda: |-
const char *name = "Clock";
if (to == id(page_forecast)) name = "Forecast";
else if (to == id(page_agenda)) name = "Agenda";
else if (to == id(page_dashboard)) name = "Dashboard";
else if (to == id(page_media)) name = "Now playing";
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";
if (id(page_select).current_option() != name) id(page_select).publish_state(name);
pages:
# 1. Clock, date and current weather
- id: page_clock
lambda: |-
auto now = id(ha_time).now();
if (now.is_valid()) {
it.strftime(150, 100, id(f_clock), TextAlign::CENTER, "%H:%M", now);
it.strftime(150, 192, id(f_m), TextAlign::CENTER, "%A %d %B", now);
}
it.vertical_line(302, 24, 208);
const std::string &cond = id(weather_cond).state;
it.print(408, 64, id(icons_xl), TextAlign::CENTER, el512::weather_icon(cond));
if (!std::isnan(id(weather_temp).state))
it.printf(408, 140, id(f_big), TextAlign::CENTER, "%.1f°", id(weather_temp).state);
it.print(408, 184, id(f_m), TextAlign::CENTER, el512::weather_text(cond).c_str());
if (!std::isnan(id(weather_hum).state))
it.printf(408, 214, id(f_s), TextAlign::CENTER, "%.0f%% humidity %.0f km/h",
id(weather_hum).state, id(weather_wind).state);
# 2. Forecast (one column per day)
- id: page_forecast
lambda: |-
auto days = el512::split(id(forecast_days).state, ';');
const int n = std::min<int>(days.size(), 7);
if (n == 0) {
it.print(256, 128, id(f_m), TextAlign::CENTER, "No forecast (sensor.el512_forecast)");
return;
}
const int w = 512 / n;
for (int i = 0; i < n; i++) {
const int x = w * i + w / 2;
if (i > 0) it.vertical_line(w * i, 16, 224);
it.print(x, 10, id(f_m), TextAlign::TOP_CENTER, el512::field(days[i], 0).c_str());
it.print(x, 98, id(icons_l), TextAlign::CENTER, el512::weather_icon(el512::field(days[i], 1)));
it.printf(x, 140, id(f_l), TextAlign::TOP_CENTER, "%s°", el512::field(days[i], 2).c_str());
it.printf(x, 180, id(f_m), TextAlign::TOP_CENTER, "%s°", el512::field(days[i], 3).c_str());
it.print(x - 4, 224, id(icons_s), TextAlign::TOP_RIGHT, "\U000F054B");
it.printf(x - 2, 222, id(f_s), TextAlign::TOP_LEFT, "%s%%", el512::field(days[i], 4).c_str());
}
# 3. Calendar agenda
- id: page_agenda
lambda: |-
it.print(8, 4, id(icons_m), TextAlign::TOP_LEFT, "\U000F0E17");
it.print(48, 8, id(f_m), TextAlign::TOP_LEFT, "Agenda");
auto now = id(ha_time).now();
if (now.is_valid()) it.strftime(504, 10, id(f_s), TextAlign::TOP_RIGHT, "%A %d %B", now);
it.horizontal_line(0, 40, 512);
auto events = el512::split(id(agenda_events).state, ';');
if (events.empty()) {
it.print(256, 140, id(f_m), TextAlign::CENTER, "Nothing planned");
return;
}
int y = 48;
std::string last_day;
for (auto &e : events) {
if (y > 256 - 24) break;
const std::string day = el512::field(e, 0), time = el512::field(e, 1);
if (day != last_day) {
if (!last_day.empty()) it.horizontal_line(8, y - 2, 496);
it.print(8, y, id(f_m), TextAlign::TOP_LEFT, day.c_str());
last_day = day;
}
it.print(96, y, id(f_m), TextAlign::TOP_LEFT, time.empty() ? "all day" : time.c_str());
it.print(176, y, id(f_m), TextAlign::TOP_LEFT, el512::fit(id(f_m), el512::field(e, 2), 330).c_str());
y += 26;
}
# 4. Sensor dashboard and message line
- id: page_dashboard
lambda: |-
struct Tile { const char *name; float value; };
const Tile tiles[9] = {
{"Outside", id(weather_temp).state}, {"Woonkamer", id(t_woonkamer).state},
{"Voorkamer", id(t_voorkamer).state}, {"Gang", id(t_gang).state},
{"Patrick", id(t_patrick).state}, {"Badkamer", id(t_badkamer).state},
{"Stans & Paul", id(t_stans_paul).state}, {"Vloerverwarming", id(t_vloer).state},
{"Huis", id(t_huis).state},
};
for (int i = 0; i < 9; i++) {
const int x = (i % 3) * 171, y = (i / 3) * 68;
it.print(x + 10, y + 6, id(f_s), TextAlign::TOP_LEFT, tiles[i].name);
if (std::isnan(tiles[i].value))
it.print(x + 10, y + 24, id(f_l), TextAlign::TOP_LEFT, "--");
else
it.printf(x + 10, y + 24, id(f_l), TextAlign::TOP_LEFT, "%.1f°", tiles[i].value);
}
it.vertical_line(170, 4, 196);
it.vertical_line(341, 4, 196);
it.horizontal_line(0, 210, 512);
const std::string &msg = id(panel_message).state;
if (!msg.empty()) {
it.print(256, 234, id(f_m), TextAlign::CENTER, el512::fit(id(f_m), msg, 496).c_str());
} else {
const float net = id(grid_import).state - id(grid_export).state;
it.printf(256, 234, id(f_s), TextAlign::CENTER, "Solar %s House %s Grid %s",
el512::watts(id(solar_power).state).c_str(), el512::watts(id(house_power).state).c_str(),
el512::watts(net).c_str());
}
# 5. Now playing (whichever media player plays)
- id: page_media
lambda: |-
auto f = el512::split(id(np_info).state, '|'); // title|artist|album|player|pos|dur
if (f.size() < 6 || id(np_state).state == "idle") {
it.print(256, 118, id(icons_m), TextAlign::CENTER, "\U000F0387");
it.print(256, 160, id(f_m), TextAlign::CENTER, "Nothing playing");
return;
}
it.print(8, 6, id(icons_m), TextAlign::TOP_LEFT, "\U000F040A");
it.print(48, 12, id(f_s), TextAlign::TOP_LEFT, el512::fit(id(f_s), f[3], 450).c_str());
it.print(8, 56, id(f_l), TextAlign::TOP_LEFT, el512::fit(id(f_l), f[0], 496).c_str());
it.print(8, 104, id(f_m), TextAlign::TOP_LEFT, el512::fit(id(f_m), f[1], 496).c_str());
it.print(8, 136, id(f_s), TextAlign::TOP_LEFT, el512::fit(id(f_s), f[2], 496).c_str());
const int dur = atoi(f[5].c_str());
int pos = atoi(f[4].c_str()) + (int) ((millis() - id(np_updated)) / 1000);
if (dur > 0) {
pos = std::min(pos, dur);
it.rectangle(8, 190, 496, 14);
it.filled_rectangle(8, 190, 496 * pos / dur, 14);
it.print(8, 212, id(f_s), TextAlign::TOP_LEFT, el512::mmss(pos).c_str());
it.print(504, 212, id(f_s), TextAlign::TOP_RIGHT, el512::mmss(dur).c_str());
}
# 6. Plex: streams, server status (Plex + Tautulli integrations)
- id: page_plex
lambda: |-
const bool online = id(plex_server).state != "unavailable" && !id(plex_server).state.empty();
it.print(8, 4, id(icons_m), TextAlign::TOP_LEFT, "\U000F06BA");
it.print(48, 8, id(f_m), TextAlign::TOP_LEFT, "Plex");
it.print(504, 4, id(icons_m), TextAlign::TOP_RIGHT, online ? "\U000F048D" : "\U000F048E");
std::string ver = id(plex_version).state;
ver = ver.substr(0, ver.find('-'));
it.printf(462, 10, id(f_s), TextAlign::TOP_RIGHT, "%s%s%s", online ? "online" : "offline",
ver.empty() ? "" : " v", ver.c_str());
if (id(plex_update).state == "on")
it.print(300, 10, id(f_s), TextAlign::TOP_RIGHT, "update available");
it.horizontal_line(0, 40, 512);
// Left: stream count and bandwidth
const float n = id(plex_watching).state;
it.printf(70, 100, id(f_big), TextAlign::CENTER, "%.0f", std::isnan(n) ? 0.0f : n);
it.print(70, 134, id(f_s), TextAlign::TOP_CENTER, n == 1 ? "stream" : "streams");
if (!std::isnan(id(tautulli_bandwidth).state))
it.printf(70, 170, id(f_m), TextAlign::TOP_CENTER, "%.1f Mbit/s", id(tautulli_bandwidth).state / 1000.0f);
it.vertical_line(140, 48, 200);
// Right: who is watching what
auto streams = el512::split(id(plex_streams).state, ';');
if (streams.empty()) {
it.print(326, 140, id(f_m), TextAlign::CENTER, online ? "Nobody is watching" : "Server offline");
return;
}
int y = 50;
for (auto &s : streams) {
if (y > 256 - 48) break;
it.print(152, y, id(f_s), TextAlign::TOP_LEFT, el512::fit(id(f_s), el512::field(s, 0), 352).c_str());
it.print(152, y + 18, id(f_m), TextAlign::TOP_LEFT, el512::fit(id(f_m), el512::field(s, 1), 352).c_str());
y += 50;
}
# 7. Solar: production now and today, house and grid, graph of the last 16 h
- id: page_solar
lambda: |-
it.print(8, 8, id(icons_m), TextAlign::TOP_LEFT, "\U000F0A72");
const float sp = std::max(0.0f, id(solar_power).state);
it.print(50, 0, id(f_big), TextAlign::TOP_LEFT, el512::watts(sp).c_str());
if (!std::isnan(id(solar_today).state))
it.printf(50, 54, id(f_s), TextAlign::TOP_LEFT, "today %.2f kWh", id(solar_today).state / 1000.0f);
it.print(300, 8, id(icons_m), TextAlign::TOP_LEFT, "\U000F06A1");
it.print(340, 12, id(f_m), TextAlign::TOP_LEFT, el512::watts(id(house_power).state).c_str());
const float net = id(grid_import).state - id(grid_export).state;
it.print(300, 48, id(icons_m), TextAlign::TOP_LEFT, "\U000F0D3E");
it.print(340, 52, id(f_m), TextAlign::TOP_LEFT, el512::watts(std::fabs(net)).c_str());
it.print(440, 48, id(icons_m), TextAlign::TOP_LEFT, net > 0 ? "\U000F072E" : "\U000F0737");
it.graph(8, 128, id(solar_graph));
it.print(12, 110, id(f_s), TextAlign::TOP_LEFT, "last 16 h");
# 8. Portal end credits (started from HA; returns to the previous page)
- id: page_still_alive
lambda: |-
id(panel).draw_still_alive();
+31
View File
@@ -0,0 +1,31 @@
# Minimal bring-up test for the el512 component (no Wi-Fi).
esphome:
name: el512-smoke
esp32:
board: wemos_d1_mini32
framework:
type: esp-idf
logger:
external_components:
- source:
type: local
path: ../components
font:
- file: "gfonts://Roboto Mono"
id: mono
size: 20
display:
- platform: el512
id: panel
update_interval: 1s
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height());
it.line(0, 0, it.get_width() - 1, it.get_height() - 1);
it.print(10, 10, id(mono), "ESPHome on EL512.256-H");
it.printf(10, 40, id(mono), "uptime %lus", (unsigned long) (millis() / 1000));
it.filled_circle(it.get_width() / 2, it.get_height() / 2, 40);
+133
View File
@@ -0,0 +1,133 @@
# Helper sensors for the EL512.256-H ESPHome display (esphome/el512-panel.yaml).
#
# Install: put this file in /config/packages/ and make sure configuration.yaml has
#
# homeassistant:
# packages: !include_dir_named packages
#
# then restart Home Assistant. All lists use the same compact text format so the
# ESP32 can split them cheaply: fields separated by "|", entries by ";".
template:
# Forecast and agenda come from actions, so they refresh on a timer.
- triggers:
- trigger: time_pattern
minutes: "/15"
- trigger: homeassistant
event: start
- trigger: event
event_type: event_template_reloaded
actions:
- action: weather.get_forecasts
target:
entity_id: weather.knmi_home
data:
type: daily
response_variable: fc
- action: calendar.get_events
target:
entity_id:
- calendar.mangavalk_gmail_com
- calendar.li_s_happy_home
- calendar.feestdagen_in_nederland
data:
duration:
days: 14
response_variable: cal
sensor:
# days: "Sun|cloudy|12|11|0;Mon|partlycloudy|14|10|20;..."
# weekday|condition|high|low|rain chance %
- name: EL512 forecast
unique_id: el512_forecast
icon: mdi:weather-partly-cloudy
state: "{{ fc['weather.knmi_home'].forecast | count }}"
attributes:
days: >-
{%- set ns = namespace(out=[]) -%}
{%- for d in fc['weather.knmi_home'].forecast[:7] -%}
{%- set ns.out = ns.out + [
(as_datetime(d.datetime) | as_local).strftime('%a') ~ '|' ~
d.condition ~ '|' ~
(d.temperature | round(0) | int) ~ '|' ~
(d.templow | default(d.temperature) | round(0) | int) ~ '|' ~
(d.precipitation_probability | default(0) | int)] -%}
{%- endfor -%}
{{ ns.out | join(';') }}
# events: "Mon 19|11:00|Work;Tue 20||Holiday;..."
# day|start time (empty = all day)|title
- name: EL512 agenda
unique_id: el512_agenda
icon: mdi:calendar
state: >-
{%- set ns = namespace(n=0) -%}
{%- for c in cal.values() -%}{%- set ns.n = ns.n + c.events | count -%}{%- endfor -%}
{{ ns.n }}
attributes:
events: >-
{%- set ns = namespace(ev=[]) -%}
{%- for c in cal.values() -%}
{%- for e in c.events -%}{%- set ns.ev = ns.ev + [e] -%}{%- endfor -%}
{%- endfor -%}
{%- set out = namespace(items=[]) -%}
{%- for e in (ns.ev | sort(attribute='start'))[:10] -%}
{%- if e.start | length == 10 -%}
{%- set day = strptime(e.start, '%Y-%m-%d').strftime('%a %d') -%}
{%- set time = '' -%}
{%- else -%}
{%- set t = as_datetime(e.start) | as_local -%}
{%- set day = t.strftime('%a %d') -%}
{%- set time = t.strftime('%H:%M') -%}
{%- endif -%}
{%- set title = (e.summary | default('')) | replace('|', '/') | replace(';', ',') -%}
{%- set out.items = out.items + [day ~ '|' ~ time ~ '|' ~ title[:40]] -%}
{%- endfor -%}
{{ out.items | join(';') }}
# Whatever media player is playing right now; position advances every 5 s.
- triggers:
- trigger: time_pattern
seconds: "/5"
sensor:
- name: EL512 now playing
unique_id: el512_now_playing
icon: mdi:play-circle
state: >-
{%- set p = states.media_player | selectattr('state', 'eq', 'playing') | list -%}
{{ p[0].entity_id if p else 'idle' }}
attributes:
# "title|artist|album|player|position s|duration s"
info: >-
{%- set p = states.media_player | selectattr('state', 'eq', 'playing') | list -%}
{%- if p -%}
{%- set a = p[0].attributes -%}
{%- set pos = a.media_position | default(0) -%}
{%- if a.media_position_updated_at is defined and a.media_position_updated_at -%}
{%- set pos = pos + (now() - as_datetime(a.media_position_updated_at)).total_seconds() -%}
{%- endif -%}
{%- set dur = a.media_duration | default(0) -%}
{%- set pos = [pos, dur] | min if dur else pos -%}
{{ [a.media_title | default(a.app_name | default('')), a.media_artist | default(a.media_series_title | default('')),
a.media_album_name | default(''), a.friendly_name | default(''), pos | int, dur | int]
| map('string') | map('replace', '|', '/') | join('|') }}
{%- endif -%}
# Plex: the server sensor has one attribute per stream ("user - client": "title").
- sensor:
- name: EL512 Plex streams
unique_id: el512_plex_streams
icon: mdi:plex
state: "{{ states('sensor.mangavalkserver') }}"
attributes:
# "user - client|title;..."
streams: >-
{%- set s = states.sensor.mangavalkserver -%}
{%- set ns = namespace(out=[]) -%}
{%- if s -%}
{%- for k, v in s.attributes.items()
if k not in ['unit_of_measurement', 'friendly_name', 'icon', 'device_class', 'state_class'] -%}
{%- set ns.out = ns.out + [(k | replace('|', '/') | replace(';', ',')) ~ '|' ~
(v | string | replace('|', '/') | replace(';', ','))] -%}
{%- endfor -%}
{%- endif -%}
{{ ns.out | join(';') }}
-261
View File
@@ -1,261 +0,0 @@
#include "EL512Panel.h"
#include <driver/periph_ctrl.h>
#include <esp32/rom/lldesc.h>
#include <esp_heap_caps.h>
#include <esp_rom_gpio.h>
#include <soc/gpio_sig_map.h>
#include <soc/i2s_reg.h>
#include <soc/i2s_struct.h>
#include <string.h>
namespace {
// I2S0 clock source when clka_en = 0: PLL_D2_CLK, 160 MHz whatever the CPU clock.
constexpr uint32_t I2S_SOURCE_CLOCK_HZ = 160000000;
constexpr uint32_t I2S_BYTE_RATE_HZ = EL512Panel::PIXEL_CLOCK_HZ * EL512Panel::BYTES_PER_CLOCK;
constexpr uint32_t I2S_CLKM_DIV = I2S_SOURCE_CLOCK_HZ / I2S_BYTE_RATE_HZ;
static_assert(I2S_SOURCE_CLOCK_HZ % I2S_BYTE_RATE_HZ == 0,
"byte rate must divide 160 MHz exactly");
static_assert(I2S_CLKM_DIV >= 2 && I2S_CLKM_DIV <= 255, "clkm_div_num out of range");
// In 8-bit LCD mode I2S0 puts its byte on DATA_OUT16..23 (bitluni ESP32Lib).
constexpr uint32_t I2S0_DATA_SIGNAL_BASE = I2S0O_DATA_OUT16_IDX;
// BCK runs at half the byte rate (measured: 2.5 MHz BCK at 5 MHz bytes), so each
// VCLK period spans two identical bytes. Uninverted, the rising edge landed on the
// byte-pair boundary and the image jittered; inverted, it sits mid-pair (verified
// on the panel).
constexpr bool INVERT_VCLK = true;
// One DMA descriptor per stream chunk (descriptor limit 4095 bytes).
static_assert(EL512Panel::LINES_PER_CHUNK * EL512Panel::ROW_BYTES <= 4092, "descriptor too long");
static_assert(EL512Panel::ROW_BYTES % 4 == 0, "rows must be whole DMA words");
void resetI2S0() {
const uint32_t lcReset = I2S_IN_RST_M | I2S_OUT_RST_M | I2S_AHBM_RST_M | I2S_AHBM_FIFO_RST_M;
I2S0.lc_conf.val |= lcReset;
I2S0.lc_conf.val &= ~lcReset;
const uint32_t confReset =
I2S_RX_RESET_M | I2S_RX_FIFO_RESET_M | I2S_TX_RESET_M | I2S_TX_FIFO_RESET_M;
I2S0.conf.val |= confReset;
I2S0.conf.val &= ~confReset;
}
void routeOutput(int8_t pin, uint32_t signal, bool invert) {
if (pin < 0) return;
pinMode(pin, OUTPUT);
esp_rom_gpio_connect_out_signal(pin, signal, invert, false);
}
} // namespace
EL512Panel::EL512Panel(const EL512Pins& pins) : _pins(pins) {}
EL512Panel::~EL512Panel() { end(); }
bool EL512Panel::begin() {
if (_running) return true;
pinMode(_pins.enable, OUTPUT);
pinMode(_pins.lowpwr_n, OUTPUT);
setEnabled(false);
setLowPower(false);
_fb = (uint8_t*)calloc(FRAMEBUFFER_BYTES, 1);
// Full-frame stream: 261 * 544 = ~142 KB of DMA-capable internal RAM, in chunks.
// (A line ring buffer refilled from the DMA EOF interrupt would cut this
// to a few KB if RAM gets tight.)
bool ok = _fb != nullptr;
for (uint16_t i = 0; ok && i < CHUNK_COUNT; i++) {
_chunks[i] = (uint8_t*)heap_caps_malloc((size_t)chunkLines(i) * ROW_BYTES,
MALLOC_CAP_DMA | MALLOC_CAP_8BIT);
ok = _chunks[i] != nullptr;
}
if (!ok) {
end();
return false;
}
show();
if (!startI2S()) {
end();
return false;
}
_running = true;
setEnabled(true);
return true;
}
void EL512Panel::end() {
setEnabled(false);
stopI2S();
_running = false;
free(_fb);
_fb = nullptr;
for (uint8_t*& chunk : _chunks) {
heap_caps_free(chunk);
chunk = nullptr;
}
}
uint16_t EL512Panel::chunkLines(uint16_t chunk) {
const uint16_t first = chunk * LINES_PER_CHUNK;
return min<uint16_t>(LINES_PER_CHUNK, LINES_PER_FRAME - first);
}
uint8_t* EL512Panel::linePtr(uint16_t line) const {
return _chunks[line / LINES_PER_CHUNK] + (size_t)(line % LINES_PER_CHUNK) * ROW_BYTES;
}
void EL512Panel::setEnabled(bool on) { digitalWrite(_pins.enable, on ? HIGH : LOW); }
void EL512Panel::setLowPower(bool on) { digitalWrite(_pins.lowpwr_n, on ? LOW : HIGH); }
void EL512Panel::setPixel(int16_t x, int16_t y, bool on) {
if (!_fb || x < 0 || y < 0 || x >= WIDTH || y >= HEIGHT) return;
uint8_t& b = _fb[(y * WIDTH + x) >> 3];
const uint8_t mask = 0x80 >> (x & 7);
if (on)
b |= mask;
else
b &= ~mask;
}
bool EL512Panel::getPixel(int16_t x, int16_t y) const {
if (!_fb || x < 0 || y < 0 || x >= WIDTH || y >= HEIGHT) return false;
return _fb[(y * WIDTH + x) >> 3] & (0x80 >> (x & 7));
}
void EL512Panel::clear(bool on) {
if (_fb) memset(_fb, on ? 0xFF : 0x00, FRAMEBUFFER_BYTES);
}
void EL512Panel::encodeRow(uint16_t line, uint8_t* out) const {
// VS is held high during the last blanking line so that the first HS high
// time ending after its rising edge is row 0 (datasheet: Video Data Input).
// VS rises 1104 clocks after the previous HS fall (T4 >= 1 VCLK) and 272
// clocks = 54.4 us before the next HS rise (T5 >= 3 us); check on a scope.
const uint8_t vs = (line == LINES_PER_FRAME - 1) ? BIT_VS : 0;
if (line >= HEIGHT) {
memset(out, vs, ROW_BYTES);
return;
}
const uint8_t* src = _fb + (size_t)line * (WIDTH / 8);
for (uint16_t c = 0; c < DATA_CLOCKS_PER_ROW; c++) {
// Clock c carries x = 2c and 2c+1. The datasheet numbers columns from 1, so
// x = 2c is column 2c+1 (odd -> TVID) and x = 2c+1 is column 2c+2 (even -> VID).
const uint8_t bits = (uint8_t)(src[c >> 2] << ((c & 3) * 2));
uint8_t v = BIT_HS | vs;
if (bits & 0x80) v |= BIT_TVID;
if (bits & 0x40) v |= BIT_VID;
// Two bytes per VCLK; 8-bit I2S LCD mode sends each 32-bit word as bytes 2, 3, 0, 1.
const uint16_t i = c * BYTES_PER_CLOCK;
out[i ^ 2] = v;
out[(i + 1) ^ 2] = v;
}
// Blanking is uniform and starts on a word boundary, so it needs no swap.
memset(out + DATA_CLOCKS_PER_ROW * BYTES_PER_CLOCK, vs, BLANK_CLOCKS_PER_ROW * BYTES_PER_CLOCK);
}
void EL512Panel::show() {
if (!_fb || !_chunks[CHUNK_COUNT - 1]) return;
// TODO: double-buffer or sync to VS to avoid tearing.
for (uint16_t line = 0; line < LINES_PER_FRAME; line++) {
encodeRow(line, linePtr(line));
}
}
bool EL512Panel::startI2S() {
// Circular descriptor chain over the stream, one descriptor per chunk.
_desc = (lldesc_t*)heap_caps_calloc(CHUNK_COUNT, sizeof(lldesc_t), MALLOC_CAP_DMA);
if (!_desc) return false;
for (size_t i = 0; i < CHUNK_COUNT; i++) {
lldesc_t& d = _desc[i];
d.size = chunkLines(i) * ROW_BYTES;
d.length = d.size;
d.owner = 1;
d.eof = (i == CHUNK_COUNT - 1); // one EOF per frame
d.buf = _chunks[i];
d.qe.stqe_next = &_desc[(i + 1) % CHUNK_COUNT];
}
periph_module_enable(PERIPH_I2S0_MODULE);
resetI2S0();
// 8-bit parallel LCD mode, one byte per BCK period (bitluni ESP32Lib setup;
// the c ^ 2 byte order in encodeRow() depends on these FIFO/channel modes).
I2S0.conf2.val = 0;
I2S0.conf2.lcd_en = 1;
I2S0.conf2.lcd_tx_wrx2_en = 1;
I2S0.conf2.lcd_tx_sdx2_en = 0;
I2S0.sample_rate_conf.val = 0;
I2S0.sample_rate_conf.tx_bits_mod = 8;
I2S0.sample_rate_conf.tx_bck_div_num = 1;
// PLL_D2_CLK / (N + b/a) with b = 0: 160 MHz / 16 = 10 MHz bytes -> 5 MHz BCK.
I2S0.clkm_conf.val = 0;
I2S0.clkm_conf.clka_en = 0;
I2S0.clkm_conf.clkm_div_num = I2S_CLKM_DIV;
I2S0.clkm_conf.clkm_div_a = 1;
I2S0.clkm_conf.clkm_div_b = 0;
I2S0.fifo_conf.val = 0;
I2S0.fifo_conf.tx_fifo_mod_force_en = 1;
I2S0.fifo_conf.tx_fifo_mod = 1;
I2S0.fifo_conf.tx_data_num = 32;
I2S0.fifo_conf.dscr_en = 1;
I2S0.conf1.val = 0;
I2S0.conf1.tx_stop_en = 0;
I2S0.conf1.tx_pcm_bypass = 1;
I2S0.conf_chan.val = 0;
I2S0.conf_chan.tx_chan_mod = 1;
I2S0.conf.tx_right_first = 1;
I2S0.conf.tx_msb_right = 0;
I2S0.conf.tx_msb_shift = 0;
I2S0.conf.tx_mono = 0;
I2S0.conf.tx_short_sync = 0;
I2S0.timing.val = 0;
// Stream bit n -> DATA_OUT(16 + n); BCK -> VCLK.
const int8_t dataPins[] = {_pins.vid, _pins.tvid, _pins.hs, _pins.vs};
for (uint32_t bit = 0; bit < sizeof(dataPins); bit++) {
routeOutput(dataPins[bit], I2S0_DATA_SIGNAL_BASE + bit, false);
}
routeOutput(_pins.vclk, I2S0O_BCK_OUT_IDX, INVERT_VCLK);
// Start the DMA; it loops over the chain until stopI2S().
I2S0.int_ena.val = 0;
I2S0.int_clr.val = I2S0.int_raw.val;
I2S0.lc_conf.val = I2S_OUT_DATA_BURST_EN_M | I2S_OUTDSCR_BURST_EN_M;
I2S0.out_link.addr = (uint32_t)&_desc[0];
I2S0.out_link.start = 1;
I2S0.conf.tx_start = 1;
return true;
}
void EL512Panel::stopI2S() {
if (!_desc) return; // never started
I2S0.conf.tx_start = 0;
I2S0.out_link.stop = 1;
resetI2S0();
periph_module_disable(PERIPH_I2S0_MODULE);
// Hand the pins back to GPIO and park them low.
const int8_t pins[] = {_pins.vclk, _pins.vid, _pins.tvid, _pins.hs, _pins.vs};
for (int8_t pin : pins) {
if (pin < 0) continue;
esp_rom_gpio_connect_out_signal(pin, SIG_GPIO_OUT_IDX, false, false);
digitalWrite(pin, LOW);
}
heap_caps_free(_desc);
_desc = nullptr;
}
+3 -107
View File
@@ -1,109 +1,5 @@
// EL512Panel - low-level refresh layer for the Planar EL512.256-H. // The panel driver core lives in components/el512/ so the ESPHome component and
// // this Arduino library share one copy (see platformio.ini: lib_extra_dirs).
// The panel has no frame memory of its own: it must be refreshed continuously.
// We drive it from I2S0 in 8-bit parallel (LCD) mode with a circular DMA
// descriptor chain. BCK (= VCLK) rises once per two stream bytes (measured
// with PCNT), so every VCLK period is two identical bytes:
//
// byte bit 0 = VID (even columns 2..512 = x 1, 3, ..., 511)
// byte bit 1 = TVID (odd columns 1..511 = x 0, 2, ..., 510)
// byte bit 2 = HS (high while the 256 data clocks of a row are sent)
// byte bit 3 = VS (marks the top row)
// I2S BCK -> VCLK (panel samples data on the rising edge)
//
// Panel in two-bit parallel mode (PS1 jumper 2 removed, datasheet Table 3), 5 MHz VCLK:
// 272 clocks per row (256 data + 16 blanking), 261 rows per frame
// (256 visible + 5 blanking) -> 5e6 / (272 * 261) ~= 70.4 Hz.
//
// In 8-bit LCD mode the ESP32 I2S emits each 32-bit DMA word as bytes 2, 3, 0, 1,
// so encodeRow() stores stream byte i at index i ^ 2 (as bitluni's ESP32Lib does).
// The I2S byte rate is 2 * PIXEL_CLOCK_HZ = 10 MHz.
#pragma once #pragma once
#include <Arduino.h> #include "el512_panel.h"
struct lldesc_s; // ESP32 DMA descriptor (rom/lldesc.h)
struct EL512Pins {
int8_t vclk;
int8_t vid;
int8_t tvid;
int8_t hs;
int8_t vs;
int8_t enable;
int8_t lowpwr_n;
};
class EL512Panel {
public:
static constexpr uint16_t WIDTH = 512;
static constexpr uint16_t HEIGHT = 256;
// Timing (two-bit parallel mode)
static constexpr uint32_t PIXEL_CLOCK_HZ = 5000000;
static constexpr uint16_t DATA_CLOCKS_PER_ROW = WIDTH / 2; // 2 px per VCLK
static constexpr uint16_t CLOCKS_PER_ROW = 272;
static constexpr uint16_t BLANK_CLOCKS_PER_ROW = CLOCKS_PER_ROW - DATA_CLOCKS_PER_ROW;
static constexpr uint16_t LINES_PER_FRAME = 261;
static constexpr uint16_t BLANK_LINES = LINES_PER_FRAME - HEIGHT;
// DMA stream byte bits
static constexpr uint8_t BIT_VID = 1 << 0;
static constexpr uint8_t BIT_TVID = 1 << 1;
static constexpr uint8_t BIT_HS = 1 << 2;
static constexpr uint8_t BIT_VS = 1 << 3;
// Stream bytes per VCLK period (BCK runs at half the I2S byte rate).
static constexpr uint8_t BYTES_PER_CLOCK = 2;
static constexpr uint16_t ROW_BYTES = CLOCKS_PER_ROW * BYTES_PER_CLOCK;
static constexpr size_t FRAMEBUFFER_BYTES = (size_t)WIDTH * HEIGHT / 8;
static constexpr size_t STREAM_BYTES = (size_t)LINES_PER_FRAME * ROW_BYTES;
// The ~142 KB stream is too big for one DMA block, so it is allocated in
// chunks of whole lines, one per DMA descriptor (7 * 544 = 3808 <= 4092 bytes).
static constexpr uint16_t LINES_PER_CHUNK = 7;
static constexpr uint16_t CHUNK_COUNT = (LINES_PER_FRAME + LINES_PER_CHUNK - 1) / LINES_PER_CHUNK;
explicit EL512Panel(const EL512Pins& pins);
~EL512Panel();
// Allocates buffers, sets up control GPIOs and starts the I2S DMA refresh.
bool begin();
void end();
// Display on/off (ENABLE) and low-power mode (_LOWPWR, active low).
void setEnabled(bool on);
void setLowPower(bool on);
// 1 bpp framebuffer, row-major, MSB = leftmost pixel.
uint8_t* framebuffer() { return _fb; }
void setPixel(int16_t x, int16_t y, bool on);
bool getPixel(int16_t x, int16_t y) const;
void clear(bool on = false);
// Encode the framebuffer into the DMA stream (call after drawing).
void show();
// Encode one scan line (0..LINES_PER_FRAME-1) into ROW_BYTES bytes, in DMA
// order (stream byte i at index i ^ 2). Lines >= HEIGHT produce blanking.
// Public so it can be unit-tested.
void encodeRow(uint16_t line, uint8_t* out) const;
// Frame refresh rate implied by the timing constants.
static constexpr float refreshHz() {
return (float)PIXEL_CLOCK_HZ / ((float)CLOCKS_PER_ROW * LINES_PER_FRAME);
}
private:
bool startI2S();
void stopI2S();
static uint16_t chunkLines(uint16_t chunk);
uint8_t* linePtr(uint16_t line) const;
EL512Pins _pins;
uint8_t* _fb = nullptr; // 1 bpp drawing buffer
uint8_t* _chunks[CHUNK_COUNT] = {}; // stream, DMA-capable, LINES_PER_CHUNK lines each
lldesc_s* _desc = nullptr; // circular DMA descriptor chain, one per chunk
bool _running = false;
};
+2
View File
@@ -11,8 +11,10 @@ framework = arduino
monitor_speed = 115200 monitor_speed = 115200
upload_port = COM20 upload_port = COM20
monitor_port = COM20 monitor_port = COM20
lib_extra_dirs = components ; EL512Core: the shared driver in components/el512
lib_deps = lib_deps =
adafruit/Adafruit GFX Library adafruit/Adafruit GFX Library
EL512Core
build_flags = build_flags =
-Wall -Wall
-76
View File
@@ -1,76 +0,0 @@
// ASCII pictures for the bottom-right box (41 columns x 20 rows fit).
// Each picture is centred in the box. Used when there is no imported.h.
#pragma once
#include "show_data.h"
static const char* const ART_CAKE_LINES[] = {
" ) ) )",
" (_) (_) (_)",
" | | |",
" .---|-----|-----|---.",
" |~~~~~~~~~~~~~~~~~~~|",
" | o o o o |",
" |~~~~~~~~~~~~~~~~~~~|",
" | |",
" '-------------------'",
};
static const char* const ART_HEART_LINES[] = {
" .-\"\"\"-. .-\"\"\"-.",
" / \\ / \\",
" | V |",
" \\ /",
" '. .'",
" '. .'",
" '. .'",
" '. .'",
" V",
};
static const char* const ART_CHECK_LINES[] = {
" ##",
" ###",
" ###",
" ## ###",
" ### ###",
" ### ###",
" ###",
"",
" TEST PASSED",
};
static const char* const ART_PANEL_LINES[] = {
" .--------------------------.",
" | EL512.256-H 512 x 256 |",
" | |",
" | VCLK 5.0 MHz |",
" | HS 256 + 16 clk |",
" | VS 70.4 Hz |",
" | |",
" '--------------------------'",
" | | | | |",
" ESP32 I2S0 DMA",
};
#define EL_ART(a) {a, sizeof(a) / sizeof(a[0])}
// Index with the ART_* ids below.
static const Art ARTS[] = {
{nullptr, 0},
EL_ART(ART_CAKE_LINES),
EL_ART(ART_HEART_LINES),
EL_ART(ART_CHECK_LINES),
EL_ART(ART_PANEL_LINES),
};
#undef EL_ART
static constexpr size_t ART_COUNT = sizeof(ARTS) / sizeof(ARTS[0]);
enum : int8_t {
ART_CAKE = 1,
ART_HEART,
ART_CHECK,
ART_PANEL,
};
-41
View File
@@ -1,41 +0,0 @@
// Placeholder credits, used when there is no imported.h. Typed into the
// top-right box one line at a time, scrolling up, looping.
// The box is 41 columns wide; longer lines wrap.
#pragma once
#include <stddef.h>
#include <stdint.h>
static constexpr uint32_t CREDITS_START_MS = 0;
static constexpr uint32_t CREDIT_CHAR_US = 68624; // Portal's credits speed
static constexpr bool CREDITS_LOOP = true;
static const char* const CREDITS[] = {
"",
"PLANAR EL512.256-H",
"512 x 256 electroluminescent",
"",
"Driven by ......... ESP32",
"Refresh ........... I2S0 + DMA",
"Pixel clock ....... 5.0 MHz",
"Frame rate ........ 70.4 Hz",
"Stream ............ 142 KB",
"Graphics .......... Adafruit GFX",
"",
"Bugs found and fixed:",
" BCK at half the byte rate",
" VCLK on the wrong edge",
" one very loose wire",
"",
"Known issues:",
" row stripes go whoosh",
" some burn-in, it is old",
"",
"No panels were harmed",
"in the making of this demo.",
"",
"",
"",
};
static constexpr size_t CREDIT_COUNT = sizeof(CREDITS) / sizeof(CREDITS[0]);
-31
View File
@@ -1,31 +0,0 @@
// Placeholder console text, used when there is no imported.h.
//
// To play the real song text with its original timing, run
// python tools/import_still_alive.py
// which writes imported.h next to this file (see the script for details).
//
// Or write your own steps here: each one starts at startMs (from the start of
// the show), is typed out evenly over durMs, and can start a new line after it
// (STEP_NEWLINE), clear the console first (STEP_CLEAR) and switch the picture.
// The console is 41 columns x 31 rows; longer lines wrap.
#pragma once
#include "art.h"
#include "show_data.h"
static constexpr uint32_t SONG_START_MS = 0; // when to start the music
static const LyricStep LYRICS[] = {
{0, 2600, STEP_NEWLINE, ART_PANEL, "Forms EL-512256: Panel Bring-up Report"},
{3000, 0, STEP_NEWLINE, ART_KEEP, ""},
{3500, 1400, STEP_NEWLINE, ART_KEEP, "Your lyrics go here."},
{5500, 2100, STEP_NEWLINE, ART_KEEP, "Run tools/import_still_alive.py"},
{8000, 2600, STEP_NEWLINE, ART_CHECK, "to import them with the song's timing,"},
{11000, 2300, STEP_NEWLINE, ART_KEEP, "or edit src/still_alive/lyrics.h."},
{15000, 0, STEP_CLEAR, ART_KEEP, ""},
{15500, 2300, STEP_NEWLINE, ART_HEART, "Each step starts at its startMs and"},
{18000, 1500, STEP_NEWLINE, ART_KEEP, "is typed out over durMs."},
{21000, 2100, STEP_NEWLINE, ART_CAKE, "Reset the board to start over."},
};
static constexpr size_t LYRIC_COUNT = sizeof(LYRICS) / sizeof(LYRICS[0]);
+14 -165
View File
@@ -1,21 +1,11 @@
// Portal-style end-credits console on the EL512.256-H: // Portal-style end-credits show on the EL512.256-H (PlatformIO demo).
// timed lyrics on the left, typed credits top right, ASCII art bottom right. // The show itself lives in components/el512/still_alive.* and is shared with
// // the ESPHome component; its content is components/el512/still_alive_data.h.
// Content: imported.h if present (generated by tools/import_still_alive.py),
// otherwise the placeholder lyrics.h + art.h + credits.h.
#include <Arduino.h> #include <Arduino.h>
#include <EL512.h> #include <el512_panel.h>
#include <string.h> #include <still_alive.h>
#if __has_include("imported.h")
#include "imported.h"
#else
#include "art.h"
#include "credits.h"
#include "lyrics.h"
#endif
#include "pins.h" #include "pins.h"
#include "text_box.h"
static EL512Panel panel({ static EL512Panel panel({
el512pins::VCLK, el512pins::VCLK,
@@ -26,167 +16,26 @@ static EL512Panel panel({
el512pins::ENABLE, el512pins::ENABLE,
el512pins::LOWPWR_N, el512pins::LOWPWR_N,
}); });
static EL512_GFX display(panel); static el512sa::StillAlivePlayer show;
static constexpr uint32_t START_DELAY_MS = 1000; // after boot or restart
static constexpr uint32_t CURSOR_BLINK_MS = 300;
static constexpr uint32_t RESTART_MS = 10000; // after the last step
// 41 x 31 lyrics console | 41 x 9 credits over a 41 x 20 picture.
static TextBox lyricsBox(0, 0, 254, 256);
static TextBox creditsBox(256, 0, 256, 86);
static TextBox artBox(256, 88, 256, 168);
static uint32_t showStart = 0;
static size_t step = 0;
static size_t charIdx = 0;
static bool stepStarted = false;
static uint32_t finishedAt = 0;
static size_t creditIdx = 0;
static size_t creditChar = 0;
static bool creditLineOpen = false;
static bool creditsDone = false;
static uint32_t creditLineAt = 0; // show time the current credits line started
static bool due(uint32_t now, uint32_t at) { return (int32_t)(now - at) >= 0; }
static void showArt(int8_t id) {
artBox.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++) width = std::max(width, strlen(art.lines[i]));
const uint8_t top = art.count < artBox.rows() ? (artBox.rows() - art.count) / 2 : 0;
const uint8_t left = width < artBox.cols() ? (artBox.cols() - width) / 2 : 0;
for (uint8_t r = 0; r < top; r++) artBox.put('\n');
for (uint8_t i = 0; i < art.count && top + i < artBox.rows(); i++) {
if (i > 0) artBox.put('\n');
for (uint8_t c = 0; c < left; c++) artBox.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]) < artBox.cols(); p++)
artBox.put(*p);
}
}
static void startShow(uint32_t now) {
lyricsBox.clear();
creditsBox.clear();
showArt(ART_NONE);
showStart = now + START_DELAY_MS;
step = 0;
charIdx = 0;
stepStarted = false;
finishedAt = 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 < creditsBox.rows(); r++) creditsBox.put('\n');
}
// Type whatever the clock says should be on screen by now; true if anything changed.
static bool updateLyrics(uint32_t now) {
if (!due(now, showStart)) return false;
const uint32_t t = now - showStart;
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) lyricsBox.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) {
lyricsBox.put(s.text[charIdx++]);
changed = true;
}
if (charIdx < len) break; // still typing this step
if (s.flags & STEP_NEWLINE) lyricsBox.put('\n');
step++;
charIdx = 0;
stepStarted = false;
changed = true;
if (step == LYRIC_COUNT) finishedAt = now;
}
return changed;
}
// Credits: each 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.
static bool updateCredits(uint32_t now) {
if (creditsDone || !due(now, showStart)) return false;
const uint32_t t = now - showStart;
if (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;
creditsBox.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) {
creditsBox.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 setup() { void setup() {
Serial.begin(115200); Serial.begin(115200);
Serial.println("EL512.256-H still alive"); Serial.println("EL512.256-H still alive");
if (!display.begin()) { if (!panel.begin()) {
Serial.println("panel init failed (out of DMA memory?)"); Serial.println("panel init failed (out of DMA memory?)");
while (true) delay(1000); while (true) delay(1000);
} }
Serial.printf("%u lyric steps, %u pictures, %u credit lines\n", (unsigned)LYRIC_COUNT, Serial.printf("start the song %lu ms after reset\n", (unsigned long)el512sa::StillAlivePlayer::songStartMs());
(unsigned)ART_COUNT, (unsigned)CREDIT_COUNT); show.start(millis());
Serial.printf("start the song %lu ms after reset\n",
(unsigned long)(START_DELAY_MS + SONG_START_MS));
startShow(millis());
} }
void loop() { void loop() {
static bool cursorWas = false;
const uint32_t now = millis(); const uint32_t now = millis();
if (show.finished(now)) show.start(now);
if (step == LYRIC_COUNT && due(now, finishedAt + RESTART_MS)) startShow(now); if (show.update(now)) {
panel.clear();
bool dirty = updateLyrics(now); show.draw(panel);
dirty |= updateCredits(now); panel.show();
const bool cursor = (now / CURSOR_BLINK_MS) & 1;
dirty |= cursor != cursorWas;
cursorWas = cursor;
if (dirty) {
display.fillScreen(0);
lyricsBox.draw(display, cursor);
creditsBox.draw(display, cursor && !creditsDone && due(now, showStart + CREDITS_START_MS));
artBox.draw(display, false, false); // no frame around the picture, as in the game
display.display();
} }
delay(2); delay(2);
} }
-28
View File
@@ -1,28 +0,0 @@
// Data types shared by the hand-written show (lyrics.h + art.h) and the imported
// one (imported.h, generated by tools/import_still_alive.py).
#pragma once
#include <stddef.h>
#include <stdint.h>
enum : uint8_t {
STEP_NEWLINE = 1 << 0, // start a new line after the text is typed
STEP_CLEAR = 1 << 1, // clear the console before typing
};
// One timed piece of console text. Steps are sorted by startMs.
struct LyricStep {
uint32_t startMs; // from the start of the show
uint16_t durMs; // the text is typed out evenly over this time
uint8_t flags;
int8_t art; // picture to show (index into ARTS), or ART_KEEP
const char* text;
};
struct Art {
const char* const* lines;
uint8_t count;
};
static constexpr int8_t ART_KEEP = -1; // leave the current picture
static constexpr int8_t ART_NONE = 0; // ARTS[0] is always the empty picture
-89
View File
@@ -1,89 +0,0 @@
// Fixed-size character console in the GFX default 6x8 font, inside a dashed frame.
// Text wraps at the right edge and scrolls up when it runs past the bottom row.
#pragma once
#include <Adafruit_GFX.h>
#include <string.h>
class TextBox {
public:
static constexpr uint8_t MAX_COLS = 48;
static constexpr uint8_t MAX_ROWS = 32;
static constexpr int16_t PAD = 4;
static constexpr int16_t CHAR_W = 6;
static constexpr int16_t CHAR_H = 8;
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();
}
uint8_t cols() const { return _cols; }
uint8_t rows() const { return _rows; }
void clear() {
memset(_text, 0, sizeof(_text));
_row = 0;
_col = 0;
}
void put(char c) {
if (c == '\n') {
newLine();
return;
}
if (_col >= _cols) newLine();
_text[_row][_col++] = c;
}
void print(const char* s) {
while (*s) put(*s++);
}
void draw(Adafruit_GFX& g, bool cursor, bool frame = true) const {
if (frame) drawFrame(g);
g.setTextSize(1);
g.setTextColor(1);
g.setTextWrap(false);
for (uint8_t r = 0; r < _rows; r++) {
g.setCursor(_x + PAD, _y + PAD + r * CHAR_H);
g.print(_text[r]);
}
if (cursor) g.fillRect(_x + PAD + _col * CHAR_W, _y + PAD + _row * CHAR_H + 6, 5, 2, 1);
}
private:
static uint8_t fit(int16_t px, int16_t cell, uint8_t cap) {
const int n = (px - 2 * PAD) / cell;
return n > cap ? cap : n;
}
void 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]));
}
// Dashed outline, 4 px on / 2 px off.
void drawFrame(Adafruit_GFX& g) const {
for (int16_t i = 0; i < _w; i += 6) {
const int16_t n = std::min<int16_t>(4, _w - i);
g.drawFastHLine(_x + i, _y, n, 1);
g.drawFastHLine(_x + i, _y + _h - 1, n, 1);
}
for (int16_t i = 0; i < _h; i += 6) {
const int16_t n = std::min<int16_t>(4, _h - i);
g.drawFastVLine(_x, _y + i, n, 1);
g.drawFastVLine(_x + _w - 1, _y + i, n, 1);
}
}
int16_t _x, _y, _w, _h;
uint8_t _cols, _rows;
uint8_t _row = 0, _col = 0;
char _text[MAX_ROWS][MAX_COLS + 1];
};
+16 -7
View File
@@ -3,14 +3,15 @@
Reads index.html and res/stillalive.js from Reads index.html and res/stillalive.js from
https://github.com/SDSkyKlouD/still-alive-web (downloaded, or local copies) and https://github.com/SDSkyKlouD/still-alive-web (downloaded, or local copies) and
writes src/still_alive/imported.h, which the firmware uses instead of the writes components/el512/still_alive_data.h, the content of the Still Alive show
placeholder lyrics.h / art.h / credits.h when it exists. (components/el512/still_alive.cpp) used by both the PlatformIO demo and ESPHome.
python tools/import_still_alive.py # download from GitHub python tools/import_still_alive.py # download from GitHub
python tools/import_still_alive.py --html index.html --js stillalive.js python tools/import_still_alive.py --html index.html --js stillalive.js
python tools/import_still_alive.py --out somewhere/imported.h python tools/import_still_alive.py --out somewhere/still_alive_data.h
python tools/import_still_alive.py --until-ms 0 # keep the web version's outro too
imported.h is committed with a disclaimer and source reference at its top. still_alive_data.h is committed with a disclaimer and source reference at its top.
Source format: each <div class="container_lyrics" data-start=ms> is one screen Source format: each <div class="container_lyrics" data-start=ms> is one screen
(the console clears when it starts). Inside it, <span data-start data-dur (the console clears when it starts). Inside it, <span data-start data-dur
@@ -28,7 +29,11 @@ import urllib.request
from pathlib import Path from pathlib import Path
SOURCE_BASE = "https://raw.githubusercontent.com/SDSkyKlouD/still-alive-web/master/" SOURCE_BASE = "https://raw.githubusercontent.com/SDSkyKlouD/still-alive-web/master/"
OUT = Path(__file__).resolve().parent.parent / "src" / "still_alive" / "imported.h" OUT = Path(__file__).resolve().parent.parent / "components" / "el512" / "still_alive_data.h"
# The show ends after the "... are property of their respective owners." line;
# the web version's outro after it ("... is anyone there?") is left out.
UNTIL_MS = 200000
# Timing from stillalive.js. # Timing from stillalive.js.
SONG_START_MS = 6750 SONG_START_MS = 6750
@@ -146,12 +151,16 @@ def main():
ap.add_argument("--html", help="local index.html (default: download)") ap.add_argument("--html", help="local index.html (default: download)")
ap.add_argument("--js", help="local res/stillalive.js (default: download)") ap.add_argument("--js", help="local res/stillalive.js (default: download)")
ap.add_argument("--out", type=Path, default=OUT, help="output file (default: %(default)s)") ap.add_argument("--out", type=Path, default=OUT, help="output file (default: %(default)s)")
ap.add_argument("--until-ms", type=int, default=UNTIL_MS,
help="drop steps starting at or after this time, 0 = keep all (default: %(default)s)")
args = ap.parse_args() args = ap.parse_args()
page = load(args.html, "index.html") page = load(args.html, "index.html")
arts = parse_art(page) arts = parse_art(page)
art_ids = {name: i + 1 for i, name in enumerate(arts)} art_ids = {name: i + 1 for i, name in enumerate(arts)}
steps = parse_steps(page, art_ids) steps = parse_steps(page, art_ids)
if args.until_ms:
steps = [s for s in steps if s[0] < args.until_ms]
credits = parse_credits(load(args.js, "res/stillalive.js")) credits = parse_credits(load(args.js, "res/stillalive.js"))
if not steps or not arts or not credits: if not steps or not arts or not credits:
sys.exit("error: no lyrics, art or credits found; has the source changed?") sys.exit("error: no lyrics, art or credits found; has the source changed?")
@@ -168,7 +177,7 @@ def main():
"// owners. This project is not affiliated with or endorsed by Valve.", "// owners. This project is not affiliated with or endorsed by Valve.",
"//", "//",
"// Generated by tools/import_still_alive.py - do not edit by hand.", "// Generated by tools/import_still_alive.py - do not edit by hand.",
"#pragma once", "", '#include "show_data.h"', ""] "#pragma once", "", '#include "still_alive.h"', "", "namespace el512sa {", ""]
for name, lines in arts.items(): for name, lines in arts.items():
out.append(f"static const char* const ART_{name.upper()}_LINES[] = {{") out.append(f"static const char* const ART_{name.upper()}_LINES[] = {{")
out += [f" {c_string(l)}," for l in lines] out += [f" {c_string(l)}," for l in lines]
@@ -187,7 +196,7 @@ def main():
out += [f"static constexpr uint32_t SONG_START_MS = {SONG_START_MS};", out += [f"static constexpr uint32_t SONG_START_MS = {SONG_START_MS};",
f"static constexpr uint32_t CREDITS_START_MS = {CREDITS_START_MS};", f"static constexpr uint32_t CREDITS_START_MS = {CREDITS_START_MS};",
f"static constexpr uint32_t CREDIT_CHAR_US = {CREDIT_CHAR_US};", f"static constexpr uint32_t CREDIT_CHAR_US = {CREDIT_CHAR_US};",
"static constexpr bool CREDITS_LOOP = false;", ""] "static constexpr bool CREDITS_LOOP = false;", "", "} // namespace el512sa", ""]
args.out.write_text("\n".join(out), encoding="utf-8", newline="\n") args.out.write_text("\n".join(out), encoding="utf-8", newline="\n")
screens = sum(1 for s in steps if s[3] == "STEP_CLEAR") screens = sum(1 for s in steps if s[3] == "STEP_CLEAR")