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

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

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

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

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

209 lines
9.2 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Import the console text, credits, timing and ASCII art for the still_alive demo.
Reads index.html and res/stillalive.js from
https://github.com/SDSkyKlouD/still-alive-web (downloaded, or local copies) and
writes components/el512/still_alive_data.h, the content of the Still Alive show
(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 --html index.html --js stillalive.js
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
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
(the console clears when it starts). Inside it, <span data-start data-dur
data-append-br data-text data-asciiart> are typed lines with times relative to
the screen, and <br data-show-offset=ms> are extra blank lines. ASCII art comes
from <pre class="asciiart_NAME"> blocks. The credits are the CREDIT_DATA array in
stillalive.js; the page starts typing them 9 s in and starts the song 6.75 s in.
"""
import argparse
import html
import re
import sys
import urllib.request
from pathlib import Path
SOURCE_BASE = "https://raw.githubusercontent.com/SDSkyKlouD/still-alive-web/master/"
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.
SONG_START_MS = 6750
CREDITS_START_MS = 9000
CREDIT_CHAR_US = 68624 # 68.623562 ms per character
CONSOLE_COLS = 41
CONSOLE_ROWS = 31
ART_COLS = 41
ART_ROWS = 20
# The panel font is plain ASCII; fold the few typographic characters.
FOLD = {"‘": "'", "’": "'", "“": '"', "”": '"',
"–": "-", "—": "-", "…": "...", " ": " "}
def to_ascii(s):
s = "".join(FOLD.get(c, c) for c in s)
return "".join(c if 32 <= ord(c) < 127 else "?" for c in s)
def c_string(s):
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
def attr(tag, name):
m = re.search(r'\b%s="([^"]*)"' % re.escape(name), tag)
return html.unescape(m.group(1)) if m else None
def load(path, remote):
if path:
return Path(path).read_text(encoding="utf-8")
with urllib.request.urlopen(SOURCE_BASE + remote) as r:
return r.read().decode("utf-8")
def parse_credits(script):
m = re.search(r"const CREDIT_DATA = \[(.*?)\n\];", script, re.S)
if not m:
return []
# Each entry is a double-quoted JS string; undo its escapes.
return [to_ascii(re.sub(r"\\(.)", r"\1", s))
for s in re.findall(r'"((?:[^"\\]|\\.)*)"', m.group(1))]
def parse_art(page):
arts = {}
for m in re.finditer(r'<pre class="asciiart_(\w+)">(.*?)</pre>', page, re.S):
lines = [to_ascii(l.rstrip()) for l in html.unescape(m.group(2)).split("\n")]
while lines and not lines[0].strip():
lines.pop(0)
while lines and not lines[-1].strip():
lines.pop()
arts[m.group(1)] = lines
return arts
def parse_steps(page, art_ids):
# (start, order, dur, flags, art, text); order keeps source order on equal times.
steps = []
screens = list(re.finditer(r'<div class="container_lyrics[^"]*" data-start="(\d+)">', page))
for i, screen in enumerate(screens):
base = int(screen.group(1))
end = screens[i + 1].start() if i + 1 < len(screens) else len(page)
body = page[screen.end():end]
steps.append((base, len(steps), 0, "STEP_CLEAR", "ART_KEEP", ""))
# Group 2 is the whitespace after the element: a browser renders it as one
# space between spans on the same line ("because" + "we can").
for tag in re.finditer(r"<(span|br)\b[^>]*>(?:</span>)?(\s*)", body):
t = tag.group(0)
if tag.group(1) == "br":
offset = attr(t, "data-show-offset")
if offset is not None:
steps.append((base + int(offset), len(steps), 0, "STEP_NEWLINE", "ART_KEEP", ""))
continue
text = attr(t, "data-text")
if text is None or attr(t, "data-start") is None:
continue
flags = "STEP_NEWLINE" if attr(t, "data-append-br") == "true" else "0"
if flags == "0" and tag.group(2) and not text.endswith(" "):
text += " "
art = attr(t, "data-asciiart")
art_id = "ART_KEEP" if art is None else ("ART_NONE" if art == "clear" else str(art_ids.get(art, 0)))
steps.append((base + int(attr(t, "data-start")), len(steps),
min(int(attr(t, "data-dur") or 0), 65535), flags, art_id, to_ascii(text)))
steps.sort(key=lambda s: (s[0], s[1]))
return steps
def check(steps, arts):
"""Warn about anything that will not fit the panel layout."""
rows = 0
col = 0
for start, _, _, flags, _, text in steps:
if flags == "STEP_CLEAR":
rows, col = 0, 0
col += len(text)
if len(text) > CONSOLE_COLS:
print(f"warning: line at {start} ms is {len(text)} chars, wraps", file=sys.stderr)
if flags == "STEP_NEWLINE":
rows += 1
col = 0
if rows == CONSOLE_ROWS:
print(f"warning: screen overflows {CONSOLE_ROWS} rows at {start} ms, scrolls",
file=sys.stderr)
for name, lines in arts.items():
if len(lines) > ART_ROWS or max(map(len, lines), default=0) > ART_COLS:
print(f"warning: art '{name}' is larger than {ART_COLS}x{ART_ROWS}, gets cut",
file=sys.stderr)
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--html", help="local index.html (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("--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()
page = load(args.html, "index.html")
arts = parse_art(page)
art_ids = {name: i + 1 for i, name in enumerate(arts)}
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"))
if not steps or not arts or not credits:
sys.exit("error: no lyrics, art or credits found; has the source changed?")
check(steps, arts)
out = ["// Song text, timing, ASCII art and credits from Portal's ending sequence,",
"// imported from still-alive-web by SD SkyKlouD:",
"// https://github.com/SDSkyKlouD/still-alive-web",
"//",
"// Disclaimer: \"Still Alive\" was written by Jonathan Coulton for Portal (2007).",
"// Portal, its credits and ASCII art are (c) Valve Corporation, see",
"// https://store.steampowered.com/legal/. Included only for personal,",
"// non-commercial use in this hobby project; all rights remain with their",
"// owners. This project is not affiliated with or endorsed by Valve.",
"//",
"// Generated by tools/import_still_alive.py - do not edit by hand.",
"#pragma once", "", '#include "still_alive.h"', "", "namespace el512sa {", ""]
for name, lines in arts.items():
out.append(f"static const char* const ART_{name.upper()}_LINES[] = {{")
out += [f" {c_string(l)}," for l in lines]
out += ["};", ""]
out.append("static const Art ARTS[] = {")
out.append(" {nullptr, 0},")
out += [f" {{ART_{n.upper()}_LINES, {len(l)}}}, // {art_ids[n]}" for n, l in arts.items()]
out += ["};", "static constexpr size_t ART_COUNT = sizeof(ARTS) / sizeof(ARTS[0]);", ""]
out.append("static const LyricStep LYRICS[] = {")
out += [f" {{{s}, {d}, {f}, {a}, {c_string(t)}}}," for s, _, d, f, a, t in steps]
out += ["};", "static constexpr size_t LYRIC_COUNT = sizeof(LYRICS) / sizeof(LYRICS[0]);", ""]
out.append("static const char* const CREDITS[] = {")
out += [f" {c_string(c)}," for c in credits]
out += ["};", "static constexpr size_t CREDIT_COUNT = sizeof(CREDITS) / sizeof(CREDITS[0]);", ""]
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 CREDIT_CHAR_US = {CREDIT_CHAR_US};",
"static constexpr bool CREDITS_LOOP = false;", "", "} // namespace el512sa", ""]
args.out.write_text("\n".join(out), encoding="utf-8", newline="\n")
screens = sum(1 for s in steps if s[3] == "STEP_CLEAR")
print(f"wrote {args.out}: {len(steps)} steps on {screens} screens, {len(arts)} pictures, "
f"{len(credits)} credit lines, {steps[-1][0] / 1000:.0f} s")
if __name__ == "__main__":
main()